feat: migrate tools and skills to the new architecture; support profile-owned skills

This commit is contained in:
Louistiti
2026-04-30 22:02:18 +08:00
parent e67a50a48a
commit b55c47cd7f
231 changed files with 19948 additions and 529 deletions
+2
View File
@@ -6,6 +6,8 @@ __pycache__/
**/build/
**/node_modules/
**/.venv/
**/.last-skill-deps-sync
**/.last-source-deps-sync
test/coverage/
**/tmp/*
core/config/**/*.json
+22 -24
View File
@@ -6,14 +6,8 @@ import type { IntentObject, NLPAction } from '@sdk/types'
import {
CODEBASE_PATH,
LEON_HOME_PATH,
LEON_PROFILE_PATH,
LEON_TOOLKITS_PATH,
PROFILE_CONTEXT_PATH,
PROFILE_MEMORY_DB_PATH,
PROFILE_MEMORY_PATH,
PROFILE_SKILLS_PATH,
PROFILE_TOOLS_PATH
} from '@@/server/src/constants'
LEON_PROFILE_PATH
} from '@@/server/src/leon-roots'
const args = process.argv.slice(2)
const runtimeIndex = args.indexOf('--runtime')
@@ -36,15 +30,19 @@ export const RUNTIME = runtime
export {
CODEBASE_PATH,
LEON_HOME_PATH,
LEON_PROFILE_PATH,
LEON_TOOLKITS_PATH,
PROFILE_CONTEXT_PATH,
PROFILE_MEMORY_DB_PATH,
PROFILE_MEMORY_PATH,
PROFILE_SKILLS_PATH,
PROFILE_TOOLS_PATH
LEON_PROFILE_PATH
}
export const LEON_TOOLKITS_PATH = path.join(LEON_HOME_PATH, 'toolkits')
export const PROFILE_CONTEXT_PATH = path.join(LEON_PROFILE_PATH, 'context')
export const PROFILE_MEMORY_PATH = path.join(LEON_PROFILE_PATH, 'memory')
export const PROFILE_MEMORY_DB_PATH = path.join(
PROFILE_MEMORY_PATH,
'index.sqlite'
)
export const PROFILE_SKILLS_PATH = path.join(LEON_PROFILE_PATH, 'skills')
export const PROFILE_TOOLS_PATH = path.join(LEON_PROFILE_PATH, 'tools')
const BIN_PATH = path.join(LEON_HOME_PATH, 'bin')
const BRIDGES_PATH = path.join(CODEBASE_PATH, 'bridges')
const NODEJS_BRIDGE_ROOT_PATH = path.join(BRIDGES_PATH, 'nodejs')
@@ -54,11 +52,12 @@ const NODEJS_BRIDGE_VERSION_FILE_PATH = path.join(
'version.ts'
)
export const TOOLKITS_PATH = path.join(BRIDGES_PATH, 'toolkits')
export const TOOLS_PATH = path.join(CODEBASE_PATH, 'tools')
export const PROFILE_DISABLED_PATH = path.join(LEON_PROFILE_PATH, 'disabled.json')
export const [, NODEJS_BRIDGE_VERSION] = fs
.readFileSync(NODEJS_BRIDGE_VERSION_FILE_PATH, 'utf8')
.split("'")
.split('\'')
let parsedIntentObject: IntentObject | null = null
if (INTENT_OBJ_FILE_PATH) {
@@ -82,11 +81,11 @@ export const PYTORCH_TORCH_PATH = path.join(PYTORCH_PATH, 'torch')
export const SKILLS_PATH = path.join(CODEBASE_PATH, 'skills')
export const SKILL_PATH =
runtime === 'skill' && parsedIntentObject
? path.join(SKILLS_PATH, parsedIntentObject.skill_name)
? path.dirname(parsedIntentObject.skill_config_path)
: ''
const SKILL_LOCALE_CONFIG_CONTENT =
runtime === 'skill' && INTENT_OBJ_FILE_PATH && parsedIntentObject
? (() => {
? ((): SkillLocaleConfigSchema => {
const skillLocalePath = path.join(
SKILL_PATH,
'locales',
@@ -96,17 +95,16 @@ const SKILL_LOCALE_CONFIG_CONTENT =
fs.existsSync(skillLocalePath)
? fs.readFileSync(skillLocalePath, 'utf8')
: `{"variables": {}, "common_answers": {}, "widget_contents": {}, "actions": {"${parsedIntentObject.action_name}": {}}}`
)
) as SkillLocaleConfigSchema
})()
: {
variables: {},
common_answers: {},
widget_contents: {},
actions: {}
}
} satisfies SkillLocaleConfigSchema
export const SKILL_LOCALE_CONFIG: SkillLocaleConfigSchema &
SkillLocaleConfigSchema['actions'][NLPAction] = {
export const SKILL_LOCALE_CONFIG = {
variables: SKILL_LOCALE_CONFIG_CONTENT.variables,
common_answers: SKILL_LOCALE_CONFIG_CONTENT.common_answers,
widget_contents: SKILL_LOCALE_CONFIG_CONTENT.widget_contents,
@@ -115,4 +113,4 @@ export const SKILL_LOCALE_CONFIG: SkillLocaleConfigSchema &
parsedIntentObject.action_name as NLPAction
]
: {}) || {})
}
} as SkillLocaleConfigSchema & SkillLocaleConfigSchema['actions'][NLPAction]
+2 -63
View File
@@ -1,15 +1,9 @@
import path from 'node:path'
import url from 'node:url'
import { createRequire, registerHooks } from 'node:module'
import { FileHelper } from '@/helpers/file-helper'
import type { ActionFunction, ActionParams } from '@sdk/types'
import {
INTENT_OBJECT,
PROFILE_SKILLS_PATH,
SKILLS_PATH
} from '@bridge/constants'
import { INTENT_OBJECT, SKILL_PATH } from '@bridge/constants'
import { ParamsHelper } from '@sdk/params-helper'
import { leon } from '@sdk/leon'
import { setToolReporter } from '@sdk/tool-reporter'
@@ -41,58 +35,6 @@ const resolveActionFunction = (actionModule: unknown): ActionFunction | null =>
return null
}
const isBarePackageImport = (specifier: string): boolean => {
return !specifier.startsWith('.') &&
!specifier.startsWith('/') &&
!specifier.startsWith('node:') &&
!specifier.startsWith('file:')
}
const isLeonAliasImport = (specifier: string): boolean => {
return specifier.startsWith('@/') ||
specifier.startsWith('@bridge/') ||
specifier.startsWith('@sdk/') ||
specifier.startsWith('@@/')
}
const registerSkillRuntimeNodeModules = (skillName: string): void => {
const runtimeNodeModulesPath = path.join(
PROFILE_SKILLS_PATH,
skillName,
'.runtime',
'node_modules'
)
if (!FileHelper.isExistingPath(runtimeNodeModulesPath)) {
return
}
const runtimeRequire = createRequire(
path.join(runtimeNodeModulesPath, '__resolver__.cjs')
)
// Keep Leon aliases and relative imports on the default path, and only
// redirect bare package imports to the skill-local runtime dependencies.
registerHooks({
resolve(specifier, context, nextResolve) {
if (!isBarePackageImport(specifier) || isLeonAliasImport(specifier)) {
return nextResolve(specifier, context)
}
try {
const resolvedPath = runtimeRequire.resolve(specifier)
return {
shortCircuit: true,
url: url.pathToFileURL(resolvedPath).href
}
} catch {
return nextResolve(specifier, context)
}
}
})
}
async function main(): Promise<void> {
setToolReporter(async (input) => {
await leon.answer(input)
@@ -108,8 +50,6 @@ async function main(): Promise<void> {
extra_context
} = INTENT_OBJECT
registerSkillRuntimeNodeModules(skill_name)
const params: ActionParams = {
lang,
utterance: INTENT_OBJECT.utterance as ActionParams['utterance'],
@@ -129,8 +69,7 @@ async function main(): Promise<void> {
try {
const actionModule = await FileHelper.dynamicImportFromFile(
path.join(
SKILLS_PATH,
skill_name,
SKILL_PATH,
'src',
'actions',
`${action_name}.ts`
+7 -1
View File
@@ -129,7 +129,13 @@ export abstract class Tool {
*/
protected getSettingsPath(toolName?: string): string {
const resolvedToolName = toolName || this.toolName
return path.join(PROFILE_TOOLS_PATH, `${resolvedToolName}.settings.json`)
return path.join(
PROFILE_TOOLS_PATH,
this.toolkit,
resolvedToolName,
'settings.json'
)
}
/**
+13 -12
View File
@@ -2,7 +2,10 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { getPlatformName } from '@sdk/utils'
import { PROFILE_TOOLS_PATH, TOOLKITS_PATH } from '@bridge/constants'
import {
PROFILE_TOOLS_PATH,
TOOLS_PATH
} from '@bridge/constants'
interface ToolConfig {
tool_id: string
@@ -28,7 +31,7 @@ export class ToolkitConfig {
private static settingsCache = new Map<string, Record<string, unknown>>()
/**
* Load tool configuration from bridges/toolkits directory
* Load tool configuration from the flat tools structure.
* @param toolkitName - The toolkit name (e.g., 'video_streaming')
* @param toolName - Name of the tool (e.g., 'ffmpeg')
*/
@@ -37,7 +40,7 @@ export class ToolkitConfig {
// Load toolkit config if not cached
if (!this.configCache.has(cacheKey)) {
const configPath = join(TOOLKITS_PATH, toolkitName, 'toolkit.json')
const configPath = join(TOOLS_PATH, toolkitName, 'toolkit.json')
const configContent = readFileSync(configPath, 'utf-8')
const config = JSON.parse(configContent) as ToolkitConfigData
@@ -45,15 +48,8 @@ export class ToolkitConfig {
}
const toolkitConfig = this.configCache.get(cacheKey)!
const toolConfigPath = join(
TOOLKITS_PATH,
toolkitName,
'tools',
`${toolName}.tool.json`
)
const toolConfigPath = join(TOOLS_PATH, toolkitName, toolName, 'tool.json')
// toolkit.json remains the discovery surface for agent/runtime registry flows,
// but direct skill-side tool usage should still work when the tool manifest exists.
if (!toolkitConfig.tools.includes(toolName) && !existsSync(toolConfigPath)) {
throw new Error(
`Tool '${toolName}' not found in toolkit '${toolkitConfig.name}'`
@@ -82,7 +78,12 @@ export class ToolkitConfig {
return this.settingsCache.get(cacheKey) || {}
}
const settingsPath = join(PROFILE_TOOLS_PATH, `${toolName}.settings.json`)
const settingsPath = join(
PROFILE_TOOLS_PATH,
toolkitName,
toolName,
'settings.json'
)
const settingsDir = dirname(settingsPath)
mkdirSync(settingsDir, { recursive: true })
+25 -32
View File
@@ -8,6 +8,11 @@ import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Tool } from '@sdk/base-tool'
import {
TOOLS_PATH
} from '@bridge/constants'
interface ToolRuntimeCliInput {
toolkitId: string
toolId: string
@@ -52,39 +57,25 @@ const parseArgs = (): ToolRuntimeCliInput => {
}
}
const resolveToolModulePath = async (
const resolveToolModulePath = (
toolkitId: string,
toolId: string
): Promise<string | null> => {
const runtimeDir = path.dirname(fileURLToPath(import.meta.url))
const toolsRoot = path.join(runtimeDir, 'sdk', 'tools')
if (!fs.existsSync(toolsRoot)) {
return null
}
const directPath = path.join(toolsRoot, toolId, 'index.ts')
if (fs.existsSync(directPath)) {
return directPath
}
const normalizedToolId = normalizeName(toolId)
const entries = await fs.promises.readdir(toolsRoot, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
if (normalizeName(entry.name) === normalizedToolId) {
const candidate = path.join(toolsRoot, entry.name, 'index.ts')
if (fs.existsSync(candidate)) {
return candidate
}
}
): string | null => {
const flatBuiltInToolPath = path.join(
TOOLS_PATH,
toolkitId,
toolId,
'src',
'nodejs',
'index.ts'
)
if (fs.existsSync(flatBuiltInToolPath)) {
return flatBuiltInToolPath
}
return null
}
const normalizeName = (value: string): string => {
return value.replace(/[^a-z0-9]/gi, '').toLowerCase()
}
const setProjectCwd = (): void => {
const runtimeDir = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.join(runtimeDir, '..', '..', '..')
@@ -97,12 +88,14 @@ const run = async (): Promise<void> => {
try {
setProjectCwd()
const input = parseArgs()
const toolModulePath = await resolveToolModulePath(input.toolId)
const toolModulePath = resolveToolModulePath(
input.toolkitId,
input.toolId
)
if (!toolModulePath) {
throw new Error(`Tool module not found for ${input.toolId}.`)
}
const { Tool } = await import('@sdk/base-tool')
const toolManagerModule = await import('@sdk/tool-manager')
const ToolManager = toolManagerModule.default
const isMissingToolSettingsError =
@@ -113,11 +106,11 @@ const run = async (): Promise<void> => {
throw new Error(`Tool ${input.toolId} has no default export.`)
}
let toolInstance: InstanceType<typeof Tool>
let toolInstance: Tool
try {
toolInstance = (await ToolManager.initTool(
ToolClass as new () => InstanceType<typeof Tool>
)) as InstanceType<typeof Tool>
ToolClass as new () => Tool
)) as Tool
} catch (error) {
if (isMissingToolSettingsError(error)) {
process.stdout.write(
+2 -1
View File
@@ -12,7 +12,8 @@
"@aurora/*": ["../../aurora/dist/*"],
"@server/*": ["../../server/src/*"],
"@bridge/*": ["./src/*"],
"@sdk/*": ["./src/sdk/*"]
"@sdk/*": ["./src/sdk/*"],
"@tools/*": ["../../tools/*/src/nodejs/index.ts", "../../tools/*.ts"]
},
"exactOptionalPropertyTypes": false,
"declaration": true
+3 -3
View File
@@ -40,8 +40,10 @@ PROFILE_MEMORY_PATH = os.path.join(LEON_PROFILE_PATH, "memory")
PROFILE_MEMORY_DB_PATH = os.path.join(PROFILE_MEMORY_PATH, "index.sqlite")
PROFILE_SKILLS_PATH = os.path.join(LEON_PROFILE_PATH, "skills")
PROFILE_TOOLS_PATH = os.path.join(LEON_PROFILE_PATH, "tools")
PROFILE_DISABLED_PATH = os.path.join(LEON_PROFILE_PATH, "disabled.json")
SKILLS_ROOT_PATH = os.path.join(CODEBASE_PATH, "skills")
TOOLS_PATH = os.path.join(CODEBASE_PATH, "tools")
BIN_PATH = os.path.join(LEON_HOME_PATH, "bin")
BRIDGES_PATH = os.path.join(CODEBASE_PATH, "bridges")
@@ -50,9 +52,7 @@ NVIDIA_LIBS_PATH = os.path.join(BIN_PATH, "nvidia")
PYTORCH_PATH = os.path.join(BIN_PATH, "pytorch")
PYTORCH_TORCH_PATH = os.path.join(PYTORCH_PATH, "torch")
TOOLKITS_PATH = os.path.join(BRIDGES_PATH, "toolkits")
SKILL_PATH = os.path.join(SKILLS_ROOT_PATH, INTENT_OBJECT["skill_name"])
SKILL_PATH = os.path.dirname(INTENT_OBJECT["skill_config_path"])
SKILLS_PATH = SKILLS_ROOT_PATH
+42 -14
View File
@@ -2,9 +2,9 @@ import sys
import os
import inspect
from traceback import print_exc
from importlib import import_module
from importlib import util
from constants import INTENT_OBJECT, PROFILE_SKILLS_PATH
from constants import INTENT_OBJECT, SKILL_PATH
from sdk.params_helper import ParamsHelper
@@ -26,19 +26,34 @@ def resolve_action_function(skill_action_module):
return None
def main():
skill_vendor_path = os.path.abspath(
def get_skill_venv_site_packages_path():
venv_path = os.path.join(SKILL_PATH, 'src', '.venv')
candidates = [
os.path.join(
PROFILE_SKILLS_PATH,
INTENT_OBJECT['skill_name'],
'.runtime',
'vendor'
venv_path,
'Lib',
'site-packages'
),
os.path.join(
venv_path,
'lib',
f'python{sys.version_info.major}.{sys.version_info.minor}',
'site-packages'
)
)
]
if os.path.isdir(skill_vendor_path):
# Skill-specific Python dependencies are vendored at install time.
sys.path.insert(0, skill_vendor_path)
for candidate in candidates:
if os.path.isdir(candidate):
return os.path.abspath(candidate)
return None
def main():
skill_site_packages_path = get_skill_venv_site_packages_path()
if skill_site_packages_path:
sys.path.insert(0, skill_site_packages_path)
params = {
'lang': INTENT_OBJECT['lang'],
@@ -57,13 +72,26 @@ def main():
try:
sys.path.append('.')
sys.path.insert(0, os.path.dirname(SKILL_PATH))
skill_action_module = import_module(
action_path = os.path.join(
SKILL_PATH,
'src',
'actions',
INTENT_OBJECT['action_name'] + '.py'
)
spec = util.spec_from_file_location(
'skills.'
+ INTENT_OBJECT['skill_name']
+ '.src.actions.'
+ INTENT_OBJECT['action_name']
+ INTENT_OBJECT['action_name'],
action_path
)
if spec is None or spec.loader is None:
raise ImportError(f'Cannot load action module from "{action_path}"')
skill_action_module = util.module_from_spec(spec)
spec.loader.exec_module(skill_action_module)
run_function = resolve_action_function(skill_action_module)
if not callable(run_function):
+4 -2
View File
@@ -21,7 +21,6 @@ from ..constants import (
NVIDIA_LIBS_PATH,
PROFILE_TOOLS_PATH,
PYTORCH_TORCH_PATH,
TOOLKITS_PATH,
)
import subprocess
import sys
@@ -104,7 +103,10 @@ class BaseTool(ABC):
def _get_settings_path(self, tool_name: Optional[str] = None) -> str:
resolved_tool_name = tool_name or self.tool_name
return os.path.join(PROFILE_TOOLS_PATH, f"{resolved_tool_name}.settings.json")
return os.path.join(
PROFILE_TOOLS_PATH, self.toolkit, resolved_tool_name, "settings.json"
)
def _check_required_settings(self, tool_name: Optional[str] = None) -> None:
if not self.required_settings:
+7 -10
View File
@@ -2,7 +2,7 @@ import json
import os
from typing import Dict, Any, Optional
from ..constants import PROFILE_TOOLS_PATH, TOOLKITS_PATH
from ..constants import PROFILE_TOOLS_PATH, TOOLS_PATH
from .utils import get_platform_name
@@ -15,7 +15,7 @@ class ToolkitConfig:
@classmethod
def load(cls, toolkit_name: str, tool_name: str) -> Dict[str, Any]:
"""
Load tool configuration from bridges/toolkits directory
Load tool configuration from the flat tools structure.
Args:
toolkit_name: The toolkit name (e.g., 'video_streaming')
@@ -25,7 +25,7 @@ class ToolkitConfig:
# Load toolkit config if not cached
if cache_key not in cls._config_cache:
config_path = os.path.join(TOOLKITS_PATH, toolkit_name, "toolkit.json")
config_path = os.path.join(TOOLS_PATH, toolkit_name, "toolkit.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
@@ -40,13 +40,8 @@ class ToolkitConfig:
toolkit_config = cls._config_cache[cache_key]
tools_list = toolkit_config.get("tools", [])
tool_config_path = os.path.join(
TOOLKITS_PATH, toolkit_name, "tools", f"{tool_name}.tool.json"
)
tool_config_path = os.path.join(TOOLS_PATH, toolkit_name, tool_name, "tool.json")
# toolkit.json remains the discovery surface for agent/runtime registry
# flows, but direct skill-side tool usage should still work when the
# tool manifest exists.
if tool_name not in tools_list and not os.path.exists(tool_config_path):
toolkit_name_display = toolkit_config.get("name", "unknown")
raise Exception(
@@ -82,7 +77,9 @@ class ToolkitConfig:
if cache_key in cls._settings_cache:
return cls._settings_cache[cache_key]
settings_path = os.path.join(PROFILE_TOOLS_PATH, f"{tool_name}.settings.json")
settings_path = os.path.join(
PROFILE_TOOLS_PATH, toolkit_name, tool_name, "settings.json"
)
settings_dir = os.path.dirname(settings_path)
os.makedirs(settings_dir, exist_ok=True)
+1 -2
View File
@@ -6,8 +6,7 @@
".git",
"node_modules",
"server/src/tmp",
"server/dist",
"bridges/toolkits"
"server/dist"
],
"exec": "node scripts/run-with-managed-node.js node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node scripts/run-with-managed-node.js node_modules/tsx/dist/cli.mjs server/src/index.ts"
}
+1 -2
View File
@@ -6,8 +6,7 @@
".git",
"node_modules",
"server/src/tmp",
"server/dist",
"bridges/toolkits"
"server/dist"
],
"exec": "node scripts/run-with-managed-node.js node_modules/tsx/dist/cli.mjs server/src/index.ts"
}
-1
View File
@@ -66,7 +66,6 @@
"python-bridge": "tsx scripts/run-python-bridge.js server/src/intent-object.sample.json",
"train": "tsx scripts/train/run-train.js",
"prepare-release": "tsx scripts/release/prepare-release.js",
"sync-skill-deps": "tsx scripts/sync-skill-deps.js",
"check": "tsx scripts/check.js",
"kill": "tsx scripts/kill.js"
},
+2 -2
View File
@@ -4,7 +4,7 @@ import dotenv from 'dotenv'
import { LogHelper } from '@/helpers/log-helper'
import { StringHelper } from '@/helpers/string-helper'
import { DotEnvHelper } from '@/helpers/dotenv-helper'
import { ProfileHelper } from '@/helpers/profile-helper'
import { PROFILE_DOT_ENV_PATH } from '@/constants'
dotenv.config({ path: PROFILE_DOT_ENV_PATH })
@@ -25,7 +25,7 @@ const generateHTTPAPIKey = () =>
shasum.update(str)
const sha1 = shasum.digest('hex')
await DotEnvHelper.updateVariable(envVarKey, sha1)
await ProfileHelper.updateDotEnvVariable(envVarKey, sha1)
LogHelper.success('HTTP API key generated')
resolve()
+3 -3
View File
@@ -10,11 +10,11 @@ const globs = [
'aurora/src/**/*.{ts,tsx,js,jsx}',
// TODO: deal with it once handling new hotword
// '"hotword/index.{ts,js}"',
// TODO: put it back once tests have been reintroduced into skills
// '"skills/**/*.js"',
'skills/**/*.{ts,js}',
'scripts/**/*.{ts,js}',
'server/src/**/*.{ts,js}',
'test/**/*.{ts,js}'
'test/**/*.{ts,js}',
'tools/**/*.ts'
]
/**
@@ -19,9 +19,9 @@ You must create a new tool for `{TOOL_ALIAS_NAME}`. {TOOL_DESCRIPTION}
## Technical Requirements
- Tools are located under `bridges/nodejs/src/sdk/tools` and `bridges/python/src/sdk/tools`.
- Tools are located under `tools/{TOOL_TOOLKIT_NAME}/{TOOL_NAME}/src/nodejs` and `tools/{TOOL_TOOLKIT_NAME}/{TOOL_NAME}/src/python`.
- The tool must belong to the `{TOOL_TOOLKIT_NAME}` toolkit.
- Fill the `bridges/toolkits/{TOOL_TOOLKIT_NAME}/tools/{TOOL_NAME}.tool.json` file. You must provide the description, binaries, resources, function definitions by following the OpenAI function-calling standard, etc. Create the file is not created yet.
- Fill the `tools/{TOOL_TOOLKIT_NAME}/{TOOL_NAME}/tool.json` file. You must provide the description, binaries, resources, function definitions by following the OpenAI function-calling standard, etc. Create the file is not created yet.
- You must create the tool with the TypeScript SDK and the Python SDK. The business logic must literally be the same. Start by writting the TypeScript code and then translate/convert to Python for the Python tool.
- Tool file names must be `{TOOL_TS_FILE_NAME}` and `{TOOL_PYTHON_FILE_NAME}`.
- You must reuse the classes and functions provided by the SDK (network, settings, etc.). You will find them in the SDK folder.
@@ -1,160 +1,27 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import {
PNPM_RUNTIME_BIN_PATH,
PYTHON_RUNTIME_BIN_PATH,
UV_RUNTIME_BIN_PATH
} from '@/constants'
import { RuntimeHelper } from '@/helpers/runtime-helper'
import { getPyprojectDependencies } from '../setup-python-project-env'
const SYNC_STAMP_FILE_NAME = '.last-skill-deps-sync'
/**
* Stamp files let setup stay cheap on repeated boots while still re-syncing as
* soon as a skill dependency manifest changes.
*/
const getSyncStampPath = (skillPath) => {
return path.join(
RuntimeHelper.getSkillRuntimePath(skillPath),
SYNC_STAMP_FILE_NAME
)
}
const isFileEmpty = async (filePath) => {
const content = await fs.promises.readFile(filePath, 'utf8')
return content.trim() === ''
}
const isSyncCurrent = async (skillPath, manifestPath) => {
const stampPath = getSyncStampPath(skillPath)
if (!fs.existsSync(stampPath) || !fs.existsSync(manifestPath)) {
return false
}
const [stampStat, manifestStat] = await Promise.all([
fs.promises.stat(stampPath),
fs.promises.stat(manifestPath)
])
return manifestStat.mtimeMs <= stampStat.mtimeMs
}
/**
* Mark the skill as synced after a successful dependency install/update.
*/
const markSkillDependenciesAsSynced = async (skillPath) => {
await fs.promises.mkdir(RuntimeHelper.getSkillRuntimePath(skillPath), {
recursive: true
})
await fs.promises.writeFile(getSyncStampPath(skillPath), `${Date.now()}`)
}
/**
* Node skill dependencies stay inside the skill runtime directory so they do
* not leak into Leon core dependencies or other skills.
*/
const syncNodejsSkillDependencies = async (skillFriendlyName, skillPath) => {
const skillSRCPath = path.join(skillPath, 'src')
const packageJSONPath = path.join(skillSRCPath, 'package.json')
const runtimePath = RuntimeHelper.getSkillRuntimePath(skillPath)
const nodeModulesPath =
RuntimeHelper.getNodejsSkillRuntimeNodeModulesPath(skillPath)
const runtimePackageJSONPath = path.join(runtimePath, 'package.json')
if (!fs.existsSync(packageJSONPath) || (await isFileEmpty(packageJSONPath))) {
return
}
if (await isSyncCurrent(skillPath, packageJSONPath)) {
return
}
await fs.promises.mkdir(runtimePath, { recursive: true })
await fs.promises.rm(nodeModulesPath, { recursive: true, force: true })
await fs.promises.copyFile(packageJSONPath, runtimePackageJSONPath)
// Install from the runtime directory itself so pnpm does not create importer
// metadata under the skill source tree.
await execa(PNPM_RUNTIME_BIN_PATH, [
'install',
'--ignore-workspace',
'--lockfile=false'
], {
cwd: runtimePath,
env: RuntimeHelper.getManagedNodeEnvironment()
})
await markSkillDependenciesAsSynced(skillPath)
}
/**
* Vendor Python dependencies into the skill itself so each skill remains
* portable and isolated from the bridge-wide Python environment.
*/
const installPythonDependencies = async (dependencies, vendorPath) => {
await execa(UV_RUNTIME_BIN_PATH, [
'pip',
'install',
'--python',
PYTHON_RUNTIME_BIN_PATH,
'--target',
vendorPath,
...dependencies
])
}
/**
* Python skill dependencies are always re-installed into a fresh vendor
* directory so stale packages do not survive a version update.
*/
const syncPythonSkillDependencies = async (skillFriendlyName, skillPath) => {
const skillSRCPath = path.join(skillPath, 'src')
const manifestPath = path.join(skillSRCPath, 'pyproject.toml')
if (!fs.existsSync(manifestPath)) {
return
}
if (await isSyncCurrent(skillPath, manifestPath)) {
return
}
const runtimePath = RuntimeHelper.getSkillRuntimePath(skillPath)
const vendorPath = RuntimeHelper.getPythonSkillRuntimeVendorPath(skillPath)
const dependencies = await getPyprojectDependencies(skillSRCPath)
await fs.promises.mkdir(runtimePath, { recursive: true })
await fs.promises.rm(vendorPath, { recursive: true, force: true })
await fs.promises.mkdir(vendorPath, { recursive: true })
if (dependencies.length > 0) {
await installPythonDependencies(dependencies, vendorPath)
}
await markSkillDependenciesAsSynced(skillPath)
}
syncNodejsSourceDependencies,
syncPythonSourceDependencies
} from '../sync-source-dependencies'
/**
* Sync skill dependencies only when a skill is installed, updated, or its
* dependency manifest changes.
*/
export default async function syncSkillDependencies(
skillFriendlyName,
_skillFriendlyName,
currentSkill
) {
const skillSRCPath = path.join(currentSkill.path, 'src')
if (currentSkill.bridge === 'nodejs') {
await syncNodejsSkillDependencies(skillFriendlyName, currentSkill.path)
await syncNodejsSourceDependencies(skillSRCPath)
return
}
if (currentSkill.bridge === 'python') {
await syncPythonSkillDependencies(skillFriendlyName, currentSkill.path)
await syncPythonSourceDependencies(skillSRCPath)
}
}
+80
View File
@@ -0,0 +1,80 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROFILE_TOOLS_PATH, TOOLS_PATH } from '@/constants'
import { createSetupStatus } from './setup-status'
import {
syncNodejsSourceDependencies,
syncPythonSourceDependencies
} from './sync-source-dependencies'
const NODEJS_SOURCE_PATH = path.join('src', 'nodejs')
const PYTHON_SOURCE_PATH = path.join('src', 'python')
const getToolSourcePaths = async (toolsPath) => {
if (!fs.existsSync(toolsPath)) {
return []
}
const toolkitEntries = await fs.promises.readdir(toolsPath, {
withFileTypes: true
})
const sourcePaths = []
for (const toolkitEntry of toolkitEntries) {
if (!toolkitEntry.isDirectory()) {
continue
}
const toolkitPath = path.join(toolsPath, toolkitEntry.name)
const toolEntries = await fs.promises.readdir(toolkitPath, {
withFileTypes: true
})
for (const toolEntry of toolEntries) {
if (!toolEntry.isDirectory()) {
continue
}
const toolPath = path.join(toolkitPath, toolEntry.name)
sourcePaths.push({
bridge: 'nodejs',
path: path.join(toolPath, NODEJS_SOURCE_PATH)
})
sourcePaths.push({
bridge: 'python',
path: path.join(toolPath, PYTHON_SOURCE_PATH)
})
}
}
return sourcePaths
}
/**
* Sync tool dependencies next to each tool source folder.
*/
export default async function setupToolsDependencies() {
const status = createSetupStatus('Setting up tool dependencies...').start()
try {
const sourcePaths = [
...(await getToolSourcePaths(TOOLS_PATH)),
...(await getToolSourcePaths(PROFILE_TOOLS_PATH))
]
for (const sourcePath of sourcePaths) {
if (sourcePath.bridge === 'nodejs') {
await syncNodejsSourceDependencies(sourcePath.path)
} else {
await syncPythonSourceDependencies(sourcePath.path)
}
}
status.succeed('Tool dependencies: ready')
} catch (e) {
status.fail('Failed to set up tool dependencies')
throw e
}
}
+11
View File
@@ -10,6 +10,7 @@ import {
PROFILE_CONTEXT_PATH,
PROFILE_LOGS_PATH,
PROFILE_MEMORY_PATH,
PROFILE_DISABLED_PATH,
PROFILE_SKILLS_PATH,
PROFILE_TOOLS_PATH,
TMP_PATH,
@@ -36,6 +37,7 @@ import setupPython from './setup-python'
import setupUV from './setup-uv'
import setupNodejsBridgeEnv from './setup-nodejs-bridge-env'
import setupPythonBridgeEnv from './setup-python-bridge-env'
import setupToolsDependencies from './setup-tools-dependencies'
import setupSkills from './setup-skills/setup-skills'
import setupTCPServerEnv from './setup-tcp-server-env'
import setupCMake from './setup-cmake'
@@ -81,6 +83,13 @@ async function ensureLeonHomeStructure() {
fs.promises.mkdir(PROFILE_TOOLS_PATH, { recursive: true })
])
if (!fs.existsSync(PROFILE_DISABLED_PATH)) {
await fs.promises.writeFile(
PROFILE_DISABLED_PATH,
JSON.stringify({ skills: [], tools: [] }, null, 2)
)
}
status.succeed('Leon home: ready')
}
@@ -297,6 +306,8 @@ async function syncLLMSetupChoice(preferences) {
await setupPythonBridgeEnv()
currentStep = 'setupTCPServerEnv'
await setupTCPServerEnv()
currentStep = 'setupToolsDependencies'
await setupToolsDependencies()
currentStep = 'setupSkills'
await setupSkills()
if (!IS_GITHUB_ACTIONS) {
+128
View File
@@ -0,0 +1,128 @@
import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import {
PNPM_RUNTIME_BIN_PATH,
PYTHON_RUNTIME_BIN_PATH,
UV_RUNTIME_BIN_PATH
} from '@/constants'
import { RuntimeHelper } from '@/helpers/runtime-helper'
import {
getProjectVenvPythonPath,
getPyprojectDependencies
} from './setup-python-project-env'
const PACKAGE_JSON_FILE_NAME = 'package.json'
const PYPROJECT_FILE_NAME = 'pyproject.toml'
const SYNC_STAMP_FILE_NAME = '.last-source-deps-sync'
const NODE_MODULES_DIR_NAME = 'node_modules'
const VENV_DIR_NAME = '.venv'
const isFileEmpty = async (filePath) => {
const content = await fs.promises.readFile(filePath, 'utf8')
return content.trim() === ''
}
const getSyncStampPath = (sourcePath) => {
return path.join(sourcePath, SYNC_STAMP_FILE_NAME)
}
const isSyncCurrent = async (manifestPath, stampPath, dependencyPath) => {
if (
!fs.existsSync(stampPath) ||
!fs.existsSync(manifestPath) ||
!fs.existsSync(dependencyPath)
) {
return false
}
const [manifestStat, stampStat] = await Promise.all([
fs.promises.stat(manifestPath),
fs.promises.stat(stampPath)
])
return manifestStat.mtimeMs <= stampStat.mtimeMs
}
const markSourceDependenciesAsSynced = async (sourcePath) => {
await fs.promises.writeFile(getSyncStampPath(sourcePath), `${Date.now()}`)
}
/**
* Sync Node.js dependencies next to the source that declares them.
*/
export const syncNodejsSourceDependencies = async (sourcePath) => {
const packageJSONPath = path.join(sourcePath, PACKAGE_JSON_FILE_NAME)
const nodeModulesPath = path.join(sourcePath, NODE_MODULES_DIR_NAME)
const stampPath = getSyncStampPath(sourcePath)
if (!fs.existsSync(packageJSONPath) || (await isFileEmpty(packageJSONPath))) {
return
}
if (await isSyncCurrent(packageJSONPath, stampPath, nodeModulesPath)) {
return
}
await fs.promises.rm(nodeModulesPath, { recursive: true, force: true })
await execa(PNPM_RUNTIME_BIN_PATH, [
'install',
'--ignore-workspace',
'--lockfile=false'
], {
cwd: sourcePath,
env: RuntimeHelper.getManagedNodeEnvironment()
})
await markSourceDependenciesAsSynced(sourcePath)
}
/**
* Sync Python dependencies into a .venv next to the source that declares them.
*/
export const syncPythonSourceDependencies = async (sourcePath) => {
const manifestPath = path.join(sourcePath, PYPROJECT_FILE_NAME)
const venvPath = path.join(sourcePath, VENV_DIR_NAME)
const stampPath = getSyncStampPath(sourcePath)
if (!fs.existsSync(manifestPath)) {
return
}
if (
await isSyncCurrent(
manifestPath,
stampPath,
getProjectVenvPythonPath(sourcePath)
)
) {
return
}
const dependencies = await getPyprojectDependencies(sourcePath)
await fs.promises.rm(venvPath, { recursive: true, force: true })
await execa(UV_RUNTIME_BIN_PATH, [
'venv',
'--python',
PYTHON_RUNTIME_BIN_PATH,
venvPath
], { cwd: sourcePath })
if (dependencies.length > 0) {
await execa(UV_RUNTIME_BIN_PATH, [
'pip',
'install',
'--python',
getProjectVenvPythonPath(sourcePath),
...dependencies
], { cwd: sourcePath })
}
await markSourceDependenciesAsSynced(sourcePath)
}
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import { createListResult } from '@/commands/built-in-command-renderer'
import { CONFIG_STATE } from '@/core/config-states/config-state'
import { LLMProviders } from '@/core/llm-manager/types'
import { DotEnvHelper } from '@/helpers/dotenv-helper'
import { ProfileHelper } from '@/helpers/profile-helper'
const API_KEY_PARAMETER_NAME = 'api_key'
const API_KEY_INPUT_PLACEHOLDER = 'Paste API key here'
@@ -262,7 +262,7 @@ export class ModelCommand extends BuiltInCommand {
}
process.env[apiKeyEnv] = apiKey
await DotEnvHelper.updateVariable(apiKeyEnv, apiKey)
await ProfileHelper.updateDotEnvVariable(apiKeyEnv, apiKey)
await CONFIG_STATE.getModelState().setUnifiedTarget(configuredTarget)
return {
+2 -1
View File
@@ -60,6 +60,7 @@ export const PROFILE_MEMORY_PATH = path.join(LEON_PROFILE_PATH, 'memory')
export const PROFILE_LOGS_PATH = path.join(LEON_PROFILE_PATH, 'logs')
export const PROFILE_SKILLS_PATH = path.join(LEON_PROFILE_PATH, 'skills')
export const PROFILE_TOOLS_PATH = path.join(LEON_PROFILE_PATH, 'tools')
export const PROFILE_DISABLED_PATH = path.join(LEON_PROFILE_PATH, 'disabled.json')
export const PROFILE_CONVERSATION_LOG_PATH = path.join(
LEON_PROFILE_PATH,
'conversation_log.json'
@@ -72,6 +73,7 @@ export const PNPM_INSTALL_PATH = path.join(BIN_PATH, 'pnpm')
export const PYTHON_INSTALL_PATH = path.join(BIN_PATH, 'python')
export const UV_INSTALL_PATH = path.join(BIN_PATH, 'uv')
export const SKILLS_PATH = path.join(CODEBASE_PATH, 'skills')
export const TOOLS_PATH = path.join(CODEBASE_PATH, 'tools')
export const GLOBAL_CORE_PATH = path.join(CODEBASE_PATH, 'core')
export const GLOBAL_DATA_PATH = path.join(GLOBAL_CORE_PATH, 'data')
export const PROFILE_MEMORY_DB_PATH = path.join(
@@ -319,7 +321,6 @@ export const PYTORCH_VERSION = PYTORCH_VERSIONS.torch
*/
export const BINARIES_FOLDER_NAME = SystemHelper.getBinariesFolderName()
export const BRIDGES_PATH = path.join(CODEBASE_PATH, 'bridges')
export const TOOLKITS_PATH = path.join(BRIDGES_PATH, 'toolkits')
export const NODEJS_BRIDGE_ROOT_PATH = path.join(BRIDGES_PATH, 'nodejs')
export const PYTHON_BRIDGE_ROOT_PATH = path.join(BRIDGES_PATH, 'python')
export const PYTHON_TCP_SERVER_ROOT_PATH = path.join(
+5 -2
View File
@@ -15,7 +15,7 @@ import {
CONFIG_STATE_EVENT_EMITTER,
MODEL_CONFIGURATION_UPDATED_EVENT
} from '@/core/config-states/config-state-event-emitter'
import { DotEnvHelper } from '@/helpers/dotenv-helper'
import { ProfileHelper } from '@/helpers/profile-helper'
import { FileHelper } from '@/helpers/file-helper'
const GLOBAL_LLM_ENV_KEY = 'LEON_LLM'
@@ -214,7 +214,10 @@ export class ModelState {
process.env[GLOBAL_LLM_ENV_KEY] = normalizedRawTarget
await DotEnvHelper.updateVariable(GLOBAL_LLM_ENV_KEY, normalizedRawTarget)
await ProfileHelper.updateDotEnvVariable(
GLOBAL_LLM_ENV_KEY,
normalizedRawTarget
)
CONFIG_STATE_EVENT_EMITTER.emit(MODEL_CONFIGURATION_UPDATED_EVENT, {
workflowTarget: this.workflowTarget,
+5 -2
View File
@@ -3,7 +3,7 @@ import {
CONFIG_STATE_EVENT_EMITTER,
MOOD_CONFIGURATION_UPDATED_EVENT
} from '@/core/config-states/config-state-event-emitter'
import { DotEnvHelper } from '@/helpers/dotenv-helper'
import { ProfileHelper } from '@/helpers/profile-helper'
import { Moods } from '@/types'
const DEFAULT_CONFIGURED_MOOD = 'auto'
@@ -114,7 +114,10 @@ export class MoodState {
: normalizedConfiguredMood
process.env[MOOD_ENV_KEY] = normalizedConfiguredMood
await DotEnvHelper.updateVariable(MOOD_ENV_KEY, normalizedConfiguredMood)
await ProfileHelper.updateDotEnvVariable(
MOOD_ENV_KEY,
normalizedConfiguredMood
)
CONFIG_STATE_EVENT_EMITTER.emit(MOOD_CONFIGURATION_UPDATED_EVENT, {
configuredMood: this.configuredMood,
@@ -1,5 +1,5 @@
import { LEON_ROUTING_MODE } from '@/constants'
import { DotEnvHelper } from '@/helpers/dotenv-helper'
import { ProfileHelper } from '@/helpers/profile-helper'
import { RoutingMode } from '@/types'
const DEFAULT_ROUTING_MODE = RoutingMode.Smart
@@ -38,7 +38,7 @@ export class RoutingModeState {
this.routingMode = normalizedRoutingMode
process.env[ROUTING_MODE_ENV_KEY] = normalizedRoutingMode
await DotEnvHelper.updateVariable(
await ProfileHelper.updateDotEnvVariable(
ROUTING_MODE_ENV_KEY,
normalizedRoutingMode
)
@@ -9,7 +9,7 @@ import {
SERVER_CORE_PATH,
SKILLS_PATH,
TMP_PATH,
TOOLKITS_PATH
TOOLS_PATH
} from '@/constants'
import { DateHelper } from '@/helpers/date-helper'
import { ContextFile } from '@/core/context-manager/context-file'
@@ -33,7 +33,7 @@ export class HomeContextFile extends ContextFile {
`- Generated at: ${DateHelper.getDateTime()}`,
`- Codebase path: ${codebasePath}`,
`- Skills path: ${SKILLS_PATH}`,
`- Toolkits path: ${TOOLKITS_PATH}`,
`- Tools path: ${TOOLS_PATH}`,
`- Global data path: ${GLOBAL_DATA_PATH}`,
`- Models path: ${MODELS_PATH}`,
`- Context path: ${PROFILE_CONTEXT_PATH}`,
+5 -1
View File
@@ -9,6 +9,7 @@ import type { Json as NodeJQJson } from 'node-jq/lib/options'
import { LogHelper } from '@/helpers/log-helper'
import {
CODEBASE_PATH,
NODE_RUNTIME_BIN_PATH,
NODEJS_BRIDGE_TOOL_RUNTIME_SRC_PATH,
NODEJS_BRIDGE_ROOT_PATH,
@@ -668,7 +669,10 @@ export default class ToolExecutor {
{
cwd: NODEJS_BRIDGE_ROOT_PATH,
maxBuffer: 1_024 * 1_024 * 10,
env: process.env
env: {
...process.env,
LEON_CODEBASE_PATH: CODEBASE_PATH
}
}
)
const output = stdout ? stdout.toString().trim() : ''
+124 -94
View File
@@ -1,8 +1,9 @@
import fs from 'node:fs'
import path from 'node:path'
import { TOOLKITS_PATH } from '@/constants'
import { TOOLS_PATH } from '@/constants'
import { LogHelper } from '@/helpers/log-helper'
import { ProfileHelper } from '@/helpers/profile-helper'
interface ToolkitToolDefinition {
tool_id: string
@@ -255,100 +256,13 @@ export default class ToolkitRegistry {
}
try {
const entries = await fs.promises.readdir(TOOLKITS_PATH, {
withFileTypes: true
})
const toolkitsById = new Map<string, ToolkitDefinition>()
const toolkits: ToolkitDefinition[] = []
await this.loadBuiltInToolkits(toolkitsById)
for (const entry of entries) {
if (!entry.isDirectory()) {
continue
}
const toolkitId = entry.name
const toolkitPath = path.join(TOOLKITS_PATH, toolkitId)
const toolkitConfigPath = path.join(toolkitPath, 'toolkit.json')
if (!fs.existsSync(toolkitConfigPath)) {
continue
}
try {
const toolkitConfigRaw = await fs.promises.readFile(
toolkitConfigPath,
'utf-8'
)
const toolkitConfig = JSON.parse(toolkitConfigRaw) as {
name: string
description: string
icon_name: string
context_files?: string[]
tools?: string[]
}
if (!toolkitConfig.tools || toolkitConfig.tools.length === 0) {
continue
}
const contextFiles = Array.isArray(toolkitConfig.context_files)
? [
...new Set(
toolkitConfig.context_files
.map((contextFile) =>
this.normalizeContextFilename(contextFile)
)
.filter((contextFile): contextFile is string =>
Boolean(contextFile)
)
)
]
: []
const toolkitTools: Record<string, ToolkitToolDefinition> = {}
for (const toolId of toolkitConfig.tools) {
const toolConfigPath = path.join(
TOOLKITS_PATH,
toolkitId,
'tools',
`${toolId}.tool.json`
)
if (!fs.existsSync(toolConfigPath)) {
continue
}
try {
const toolConfigRaw = await fs.promises.readFile(
toolConfigPath,
'utf-8'
)
const toolConfig = JSON.parse(
toolConfigRaw
) as ToolkitToolDefinition
toolkitTools[toolId] = toolConfig
} catch (e) {
LogHelper.title('Toolkit Registry')
LogHelper.error(
`Failed to load tool config at "${toolConfigPath}": ${e}`
)
}
}
toolkits.push({
id: toolkitId,
name: toolkitConfig.name,
description: toolkitConfig.description,
iconName: toolkitConfig.icon_name,
contextFiles,
tools: toolkitTools
})
} catch (e) {
LogHelper.title('Toolkit Registry')
LogHelper.error(
`Failed to load toolkit config at "${toolkitConfigPath}": ${e}`
)
}
}
const toolkits = [...toolkitsById.values()].filter(
(toolkit) => toolkit.tools && Object.keys(toolkit.tools).length > 0
)
this._toolkits = toolkits
this._isLoaded = true
@@ -361,7 +275,123 @@ export default class ToolkitRegistry {
}
}
private normalizeContextFilename(filename: string): string | null {
private async loadBuiltInToolkits(
toolkitsById: Map<string, ToolkitDefinition>
): Promise<void> {
if (!fs.existsSync(TOOLS_PATH)) {
return
}
const entries = await fs.promises.readdir(TOOLS_PATH, {
withFileTypes: true
})
for (const entry of entries) {
if (!entry.isDirectory()) {
continue
}
const toolkitId = entry.name
const toolkitPath = path.join(TOOLS_PATH, toolkitId)
const toolkitConfigPath = path.join(toolkitPath, 'toolkit.json')
if (!fs.existsSync(toolkitConfigPath)) {
continue
}
try {
const toolkitConfig = await this.loadToolkitConfig(toolkitConfigPath)
const existingToolkit = toolkitsById.get(toolkitId)
const toolkit: ToolkitDefinition = existingToolkit || {
id: toolkitId,
name: toolkitConfig.name,
description: toolkitConfig.description,
iconName: toolkitConfig.icon_name,
contextFiles: this.normalizeContextFiles(
toolkitConfig.context_files
),
tools: {}
}
for (const toolId of toolkitConfig.tools || []) {
if (ProfileHelper.isToolDisabled(toolId)) {
continue
}
await this.loadToolConfig(
toolkit,
toolId,
path.join(toolkitPath, toolId, 'tool.json')
)
}
toolkitsById.set(toolkitId, toolkit)
} catch (e) {
LogHelper.title('Toolkit Registry')
LogHelper.error(
`Failed to load toolkit config at "${toolkitConfigPath}": ${e}`
)
}
}
}
private async loadToolkitConfig(toolkitConfigPath: string): Promise<{
name: string
description: string
icon_name: string
context_files?: string[]
tools?: string[]
}> {
return JSON.parse(
await fs.promises.readFile(toolkitConfigPath, 'utf-8')
) as {
name: string
description: string
icon_name: string
context_files?: string[]
tools?: string[]
}
}
private async loadToolConfig(
toolkit: ToolkitDefinition,
toolId: string,
toolConfigPath: string
): Promise<void> {
if (!fs.existsSync(toolConfigPath)) {
return
}
try {
const toolConfigRaw = await fs.promises.readFile(toolConfigPath, 'utf-8')
const toolConfig = JSON.parse(toolConfigRaw) as ToolkitToolDefinition
toolkit.tools = {
...(toolkit.tools || {}),
[toolId]: toolConfig
}
} catch (e) {
LogHelper.title('Toolkit Registry')
LogHelper.error(
`Failed to load tool config at "${toolConfigPath}": ${e}`
)
}
}
private normalizeContextFiles(contextFiles: unknown): string[] {
return Array.isArray(contextFiles)
? [
...new Set(
contextFiles
.map((contextFile) => this.normalizeContextFilename(contextFile))
.filter((contextFile): contextFile is string =>
Boolean(contextFile)
)
)
]
: []
}
private normalizeContextFilename(filename: unknown): string | null {
if (typeof filename !== 'string') {
return null
}
+136
View File
@@ -0,0 +1,136 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROFILE_DISABLED_PATH } from '@/constants'
import { PROFILE_DOT_ENV_PATH } from '@/leon-roots'
interface ProfileDisabledConfig {
skills?: string[]
tools?: string[]
}
const ENV_LINE_SEPARATOR_PATTERN = /\r?\n/
const ENV_VARIABLE_NAME_PATTERN = /^[A-Z0-9_]+$/
function splitEnvLines(content: string): string[] {
return content.split(ENV_LINE_SEPARATOR_PATTERN)
}
function getEnvVariableName(line: string): string | null {
const trimmedLine = line.trim()
if (
trimmedLine === '' ||
trimmedLine.startsWith('#') ||
!trimmedLine.includes('=')
) {
return null
}
const variableName = trimmedLine.slice(0, trimmedLine.indexOf('=')).trim()
return ENV_VARIABLE_NAME_PATTERN.test(variableName) ? variableName : null
}
function readDisabledConfig(): ProfileDisabledConfig {
if (!fs.existsSync(PROFILE_DISABLED_PATH)) {
return {}
}
try {
return JSON.parse(
fs.readFileSync(PROFILE_DISABLED_PATH, 'utf8')
) as ProfileDisabledConfig
} catch {
return {}
}
}
function normalizeDisabledIds(ids: unknown): Set<string> {
if (!Array.isArray(ids)) {
return new Set()
}
return new Set(
ids
.filter((id): id is string => typeof id === 'string')
.map((id) => id.trim())
.filter((id) => id.length > 0)
)
}
export class ProfileHelper {
/**
* Get disabled skill ids from the active profile.
*/
public static getDisabledSkills(): Set<string> {
return normalizeDisabledIds(readDisabledConfig().skills)
}
/**
* Get disabled tool ids from the active profile.
*/
public static getDisabledTools(): Set<string> {
return normalizeDisabledIds(readDisabledConfig().tools)
}
/**
* Check whether a skill is disabled in the active profile.
* @param skillName The skill id
*/
public static isSkillDisabled(skillName: string): boolean {
return this.getDisabledSkills().has(skillName)
}
/**
* Check whether a tool is disabled in the active profile.
* @param toolId The tool id
*/
public static isToolDisabled(toolId: string): boolean {
return this.getDisabledTools().has(toolId)
}
/**
* Upsert a single variable inside the profile `.env`.
* @param variableName The environment variable name
* @param value The environment variable value
*/
public static async updateDotEnvVariable(
variableName: string,
value: string
): Promise<void> {
const dotEnvContent = fs.existsSync(PROFILE_DOT_ENV_PATH)
? await fs.promises.readFile(PROFILE_DOT_ENV_PATH, 'utf8')
: ''
const dotEnvLines = dotEnvContent === '' ? [] : splitEnvLines(dotEnvContent)
const nextLine = `${variableName}=${value}`
let hasUpdatedLine = false
const updatedLines = dotEnvLines.map((line) => {
if (getEnvVariableName(line) !== variableName) {
return line
}
hasUpdatedLine = true
return nextLine
})
if (!hasUpdatedLine) {
updatedLines.push(nextLine)
}
const normalizedLines = updatedLines.filter(
(line, index, lines) => !(index === lines.length - 1 && line === '')
)
await fs.promises.mkdir(path.dirname(PROFILE_DOT_ENV_PATH), {
recursive: true
})
await fs.promises.writeFile(
PROFILE_DOT_ENV_PATH,
`${normalizedLines.join('\n')}\n`
)
}
}
+1 -28
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs'
import path from 'node:path'
import { LEON_HOME_PATH, LEON_PROFILE_PATH } from '@/leon-roots'
import { LEON_HOME_PATH } from '@/leon-roots'
import { SystemHelper } from '@/helpers/system-helper'
export class RuntimeHelper {
@@ -208,33 +208,6 @@ export class RuntimeHelper {
return this.firstExistingPath(venvCandidates) || this.getPythonBinPath()
}
/**
* Keep skill-owned runtime artifacts out of `src` so install/update can clean
* them up independently from skill source files.
*/
public static getSkillRuntimePath(skillPath: string): string {
return path.join(
LEON_PROFILE_PATH,
'skills',
path.basename(path.resolve(skillPath)),
'.runtime'
)
}
/**
* Resolve the runtime node_modules directory for a Node.js skill.
*/
public static getNodejsSkillRuntimeNodeModulesPath(skillPath: string): string {
return path.join(this.getSkillRuntimePath(skillPath), 'node_modules')
}
/**
* Resolve the vendored Python dependency directory for a Python skill.
*/
public static getPythonSkillRuntimeVendorPath(skillPath: string): string {
return path.join(this.getSkillRuntimePath(skillPath), 'vendor')
}
/**
* Build a shell-safe command string for execa/spawn call sites that currently
* rely on `shell: true`.
+60 -8
View File
@@ -16,6 +16,7 @@ import {
SKILLS_PATH
} from '@/constants'
import { FileHelper } from '@/helpers/file-helper'
import { ProfileHelper } from '@/helpers/profile-helper'
interface SkillDomain {
domainId: string
@@ -68,10 +69,24 @@ export class SkillDomainHelper {
* List all skill folders
*/
public static listSkillFoldersSync(): string[] {
return fs
.readdirSync(SKILLS_PATH)
.filter((folder) => folder.endsWith(SKILL_NAME_SUFFIX))
.sort()
const skillFolders = new Set<string>()
for (const skillsPath of [SKILLS_PATH, PROFILE_SKILLS_PATH]) {
if (!fs.existsSync(skillsPath)) {
continue
}
for (const folder of fs.readdirSync(skillsPath)) {
if (
folder.endsWith(SKILL_NAME_SUFFIX) &&
!ProfileHelper.isSkillDisabled(folder)
) {
skillFolders.add(folder)
}
}
}
return [...skillFolders].sort()
}
public static async listSkillFolders(): Promise<string[]> {
@@ -119,7 +134,12 @@ export class SkillDomainHelper {
public static getNewSkillConfigPath(
skillName: SkillSchema['name']
): string | null {
const skillPath = path.join(SKILLS_PATH, skillName)
const skillPath = this.resolveSkillPath(skillName)
if (!skillPath) {
return null
}
const skillConfigPath = path.join(skillPath, 'skill.json')
if (!fs.existsSync(skillConfigPath)) {
@@ -129,6 +149,28 @@ export class SkillDomainHelper {
return skillConfigPath
}
/**
* Resolve a skill source path for the active profile.
* Profile-installed skills override built-in skills with the same ID.
* @param skillName Skill name to resolve
*/
public static resolveSkillPath(skillName: SkillSchema['name']): string | null {
if (ProfileHelper.isSkillDisabled(skillName)) {
return null
}
for (const skillsPath of [PROFILE_SKILLS_PATH, SKILLS_PATH]) {
const skillPath = path.join(skillsPath, skillName)
const skillConfigPath = path.join(skillPath, 'skill.json')
if (fs.existsSync(skillConfigPath)) {
return skillPath
}
}
return null
}
/**
* Get skill guidance path (SKILL.md)
* @param skillName Skill name to get guidance path from
@@ -136,7 +178,12 @@ export class SkillDomainHelper {
public static getSkillGuidancePath(
skillName: SkillSchema['name']
): string | null {
const skillPath = path.join(SKILLS_PATH, skillName)
const skillPath = this.resolveSkillPath(skillName)
if (!skillPath) {
return null
}
const skillGuidancePath = path.join(skillPath, 'SKILL.md')
if (!fs.existsSync(skillGuidancePath)) {
@@ -439,9 +486,14 @@ export class SkillDomainHelper {
lang: ShortLanguageCode,
skillName: SkillSchema['name']
): Promise<SkillLocaleConfigSchema | object> {
const skillPath = this.resolveSkillPath(skillName)
if (!skillPath) {
return {}
}
const skillLocaleConfigPath = path.join(
SKILLS_PATH,
skillName,
skillPath,
'locales',
`${lang}.json`
)
+7 -3
View File
@@ -26,8 +26,7 @@ import { SkillDomainHelper } from '@/helpers/skill-domain-helper'
import {
MINIMUM_REQUIRED_RAM,
VOICE_CONFIG_PATH,
GLOBAL_DATA_PATH,
SKILLS_PATH
GLOBAL_DATA_PATH
} from '@/constants'
import { SystemHelper } from '@/helpers/system-helper'
@@ -161,7 +160,12 @@ const GLOBAL_DATA_SCHEMAS = {
const skillNames = await SkillDomainHelper.listSkillFolders()
for (const skillName of skillNames) {
const skillPath = path.join(SKILLS_PATH, skillName)
const skillPath = SkillDomainHelper.resolveSkillPath(skillName)
if (!skillPath) {
continue
}
const pathToSkill = path.join(skillPath, 'skill.json')
const skillObject: SkillSchema = JSON.parse(
await fs.promises.readFile(pathToSkill, 'utf8')
+4 -2
View File
@@ -10,7 +10,7 @@ export const run: ActionFunction = async function (params) {
const answer = answers[Math.floor(Math.random() * answers.length)]
if (answer === 'magical_day') {
return leon.answer({
await leon.answer({
key: 'magical_day',
data: {
weekday: LEON_BIRTH_DATE.toLocaleString(params.lang, {
@@ -21,10 +21,11 @@ export const run: ActionFunction = async function (params) {
year: LEON_BIRTH_DATE.getFullYear()
}
})
return
}
if (answer === 'commemorate') {
return leon.answer({
await leon.answer({
key: 'commemorate',
data: {
month: LEON_BIRTH_DATE.toLocaleString(params.lang, { month: 'long' }),
@@ -32,6 +33,7 @@ export const run: ActionFunction = async function (params) {
year: LEON_BIRTH_DATE.getFullYear()
}
})
return
}
const currentDate = new Date()
@@ -11,17 +11,19 @@ export const run: ActionFunction = async function (params) {
: null
if (typeof timeZone !== 'string') {
return await leon.answer({
await leon.answer({
key: 'time_zone_not_found'
})
return
}
try {
Intl.DateTimeFormat('en', { timeZone })
} catch {
return await leon.answer({
await leon.answer({
key: 'time_zone_not_found'
})
return
}
const currentDate = new Date(new Date().toLocaleString('en', { timeZone }))
@@ -16,17 +16,19 @@ const daysBetween = (date1: Date, date2: Date): number => {
export const run: ActionFunction = async function (params) {
const targetDateValue = params.action_arguments['target_date']
if (typeof targetDateValue !== 'string') {
return await leon.answer({
await leon.answer({
key: 'days_countdown_error'
})
return
}
const currentDate = new Date()
const futureDate = new Date(targetDateValue)
if (Number.isNaN(futureDate.getTime())) {
return await leon.answer({
await leon.answer({
key: 'days_countdown_error'
})
return
}
const daysCountdown = daysBetween(currentDate, futureDate)
+1 -1
View File
@@ -2,7 +2,7 @@
from bridges.python.src.sdk.leon import leon
from bridges.python.src.sdk.types import ActionParams
from bridges.python.src.sdk.tools.inference import InferenceTool
from tools.communication.inference import InferenceTool
from ..lib import memory
groups = [
@@ -6,7 +6,7 @@ import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import ElevenLabsAudioTool from '@sdk/tools/elevenlabs_audio'
import ElevenLabsAudioTool from '@tools/music_audio/elevenlabs_audio'
import {
formatBytes,
formatFilePath,
@@ -5,7 +5,7 @@ import type { ActionFunction, ActionParams } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import UltimateVocalRemoverONNXTool from '@sdk/tools/ultimate_vocal_remover_onnx'
import UltimateVocalRemoverONNXTool from '@tools/music_audio/ultimate_vocal_remover_onnx'
import { formatFilePath } from '@sdk/utils'
export const run: ActionFunction = async function (
@@ -6,11 +6,11 @@ import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import FasterWhisperTool from '@sdk/tools/faster_whisper'
import Qwen3ASRTool from '@sdk/tools/qwen3_asr'
import OpenAIAudioTool from '@sdk/tools/openai_audio'
import AssemblyAIAudioTool from '@sdk/tools/assemblyai_audio'
import ElevenLabsAudioTool from '@sdk/tools/elevenlabs_audio'
import FasterWhisperTool from '@tools/music_audio/faster_whisper'
import Qwen3ASRTool from '@tools/music_audio/qwen3_asr'
import OpenAIAudioTool from '@tools/music_audio/openai_audio'
import AssemblyAIAudioTool from '@tools/music_audio/assemblyai_audio'
import ElevenLabsAudioTool from '@tools/music_audio/elevenlabs_audio'
import { formatFilePath } from '@sdk/utils'
interface MusicAudioToolkitSkillSettings extends Record<string, unknown> {
@@ -3,11 +3,11 @@ import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import GrokTool from '@sdk/tools/grok'
import OpenRouterTool from '@sdk/tools/openrouter'
import ChatterboxONNXTool from '@sdk/tools/chatterbox_onnx'
import FfmpegTool from '@sdk/tools/ffmpeg'
import FfprobeTool from '@sdk/tools/ffprobe'
import GrokTool from '@tools/search_web/grok'
import OpenRouterTool from '@tools/communication/openrouter'
import ChatterboxONNXTool from '@tools/music_audio/chatterbox_onnx'
import FfmpegTool from '@tools/video_streaming/ffmpeg'
import FfprobeTool from '@tools/video_streaming/ffprobe'
import path from 'node:path'
import fs from 'node:fs/promises'
import os from 'node:os'
@@ -233,7 +233,7 @@ Generate the script as a JSON object with this structure:
// Calculate total duration by measuring each segment
let totalDurationMs = 0
const segmentsWithTiming: Array<{ path: string; startMs: number }> = []
const segmentsWithTiming: Array<{ path: string, startMs: number }> = []
for (const segmentPath of segmentPaths) {
const duration = await ffprobe.getDuration(segmentPath)
@@ -3,7 +3,7 @@ import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import GrokTool from '@sdk/tools/grok'
import GrokTool from '@tools/search_web/grok'
interface SearchSkillSettings extends Record<string, unknown> {
search_provider?: string
@@ -6,7 +6,7 @@ import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import OpenCodeTool from '@sdk/tools/opencode'
import OpenCodeTool from '@tools/coding_development/opencode'
import { buildSkillPrompt, getContextFiles } from '../lib/skill-prompt'
interface SkillWriterSettings extends Record<string, unknown> {
@@ -3,20 +3,16 @@ import path from 'node:path'
import type { ActionFunction, ActionParams } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import OpenCodeTool from '@sdk/tools/opencode'
import OpenCodeTool from '@tools/coding_development/opencode'
import { buildSkillPrompt, getContextFiles } from '../lib/skill-prompt'
interface SkillWriterSettings extends Record<string, unknown> {
opencode_openrouter_model?: string
}
export const run: ActionFunction = async function (
_params: ActionParams,
_paramsHelper: ParamsHelper
) {
export const run: ActionFunction = async function (_params: ActionParams) {
try {
const description = _params.utterance
const targetPath = process.cwd()
@@ -475,7 +475,7 @@ def run(params: ActionParams, params_helper: ParamsHelper) -> None:
\`\`\`
## Available SDK Tools
When generating code, you can use these existing tools (import from '@sdk/tools/TOOL_NAME'):
When generating code, you can use these existing tools (import from '@tools/<toolkit_name>/<tool_name>'):
- **cerebras-tool**: Cerebras LLM API (chat, completion, structured output, list models)
- **openrouter-tool**: OpenRouter LLM API (chat, completion, list models)
- **ytdlp-tool**: Download videos from YouTube and other platforms
@@ -11,7 +11,8 @@ export const run: ActionFunction = async function (_params, paramsHelper) {
: await getNewestTimerMemory()
if (!timerMemory) {
return await leon.answer({ key: 'no_timer_set' })
await leon.answer({ key: 'no_timer_set' })
return
}
const { interval, finishedAt, duration } = timerMemory
+4 -2
View File
@@ -18,12 +18,14 @@ export const run: ActionFunction = async function (params) {
typeof duration.value !== 'number' ||
typeof duration.unit !== 'string'
) {
return leon.answer({ key: 'cannot_get_duration' })
await leon.answer({ key: 'cannot_get_duration' })
return
}
const normalizedUnit = duration.unit.toLowerCase()
if (!supportedUnits.includes(normalizedUnit)) {
return leon.answer({ key: 'unit_not_supported' })
await leon.answer({ key: 'unit_not_supported' })
return
}
const { value: durationValue } = duration
+2 -1
View File
@@ -7,7 +7,8 @@
"@@/*": ["../*"],
"@/*": ["../server/src/*"],
"@bridge/*": ["../bridges/nodejs/src/*"],
"@sdk/*": ["../bridges/nodejs/src/sdk/*"]
"@sdk/*": ["../bridges/nodejs/src/sdk/*"],
"@tools/*": ["../tools/*/src/nodejs/index.ts", "../tools/*.ts"]
}
}
}
@@ -6,8 +6,8 @@ import type { ActionFunction, ActionParams } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import FfmpegTool from '@sdk/tools/ffmpeg'
import YtdlpTool from '@sdk/tools/ytdlp'
import FfmpegTool from '@tools/video_streaming/ffmpeg'
import YtdlpTool from '@tools/video_streaming/ytdlp'
import { formatFilePath } from '@sdk/utils'
const isHttpUrl = (value: string): boolean => {
@@ -5,8 +5,8 @@ import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import OpenRouterTool from '@sdk/tools/openrouter'
import type { TranscriptionOutput } from '@sdk/tools/transcription-schema'
import OpenRouterTool from '@tools/communication/openrouter'
import type { TranscriptionOutput } from '@tools/music_audio/transcription-schema'
interface VideoSummarizerSettings extends Record<string, unknown> {
openrouter_model?: string | null
@@ -30,7 +30,7 @@ const buildTranscriptText = (
const truncateTranscript = (
transcript: string,
maxChars: number
): { text: string; truncated: boolean } => {
): { text: string, truncated: boolean } => {
if (transcript.length <= maxChars) {
return { text: transcript, truncated: false }
}
@@ -2,15 +2,17 @@ import fs from 'node:fs'
import path from 'node:path'
import type { ActionFunction, ActionParams } from '@sdk/types'
import type { TranscriptionOutput } from '@sdk/tools/transcription-schema'
import type { TranscriptionOutput } from '@tools/music_audio/transcription-schema'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import ChatterboxONNXTool from '@sdk/tools/chatterbox_onnx'
import Qwen3TTSTool from '@sdk/tools/qwen3_tts'
import FfmpegTool from '@sdk/tools/ffmpeg'
import FfprobeTool from '@sdk/tools/ffprobe'
import ChatterboxONNXTool from '@tools/music_audio/chatterbox_onnx'
import Qwen3TTSTool, {
type SupportedLanguage
} from '@tools/music_audio/qwen3_tts'
import FfmpegTool from '@tools/video_streaming/ffmpeg'
import FfprobeTool from '@tools/video_streaming/ffprobe'
import { formatFilePath, normalizeLanguageCode } from '@sdk/utils'
interface SpeakerReference {
@@ -68,13 +70,13 @@ function toMs(seconds: number): number {
function splitSegmentText(
segment: Segment,
maxChars: number
): Array<{ text: string; ratio: number }> {
): Array<{ text: string, ratio: number }> {
const text = segment.text.trim()
if (text.length <= maxChars) {
return [{ text, ratio: 1.0 }]
}
const chunks: Array<{ text: string; ratio: number }> = []
const chunks: Array<{ text: string, ratio: number }> = []
let remaining = text
const totalLength = text.length
@@ -314,6 +316,9 @@ export const run: ActionFunction = async function (
return
}
const resolvedTargetLanguageLabel =
targetLanguageLabel || getLanguageDisplayName(targetLanguage)
// Read and parse transcription
const transcriptionContent = await fs.promises.readFile(
translatedTranscriptionPath,
@@ -333,7 +338,7 @@ export const run: ActionFunction = async function (
data: {
segment_count: transcription.segments.length.toString(),
speaker_count: speakerReferences.length.toString(),
target_language: targetLanguageLabel,
target_language: resolvedTargetLanguageLabel,
provider
}
})
@@ -465,7 +470,7 @@ export const run: ActionFunction = async function (
const qwen3TTSTool = await ToolManager.initTool(Qwen3TTSTool)
const qwenTasks = synthesisTasks.map((task) => ({
text: task.text,
target_language: targetLanguageLabel ?? 'Auto',
target_language: resolvedTargetLanguageLabel as SupportedLanguage,
audio_path: task.audio_path,
speaker_reference_path: task.speaker_reference_path,
x_vector_only_mode: true
@@ -699,14 +704,14 @@ export const run: ActionFunction = async function (
output_path: formatFilePath(finalAudioPath),
output_folder: formatFilePath(processedSegmentsDir),
manifest_path: formatFilePath(manifestPath),
target_language: targetLanguageLabel
target_language: resolvedTargetLanguageLabel
},
core: {
context_data: {
processed_segments_dir: processedSegmentsDir,
segments_manifest_path: manifestPath,
dubbed_audio_path: finalAudioPath,
target_language: targetLanguageLabel,
target_language: resolvedTargetLanguageLabel,
target_language_code: targetLanguage
}
}
@@ -4,7 +4,7 @@ import type { ActionFunction, ActionParams } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import ECAPATool from '@sdk/tools/ecapa'
import ECAPATool from '@tools/music_audio/ecapa'
interface SpeakerReference {
speaker: string
@@ -37,7 +37,7 @@ export const run: ActionFunction = async function (
}
const tool = await ToolManager.initTool(ECAPATool)
const results: { speaker: string; gender: string }[] = []
const results: { speaker: string, gender: string }[] = []
for (const ref of speakerReferences) {
const clips = [ref.reference1_path, ref.reference2_path]
@@ -6,7 +6,7 @@ import type { ActionFunction, ActionParams } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import YtdlpTool from '@sdk/tools/ytdlp'
import YtdlpTool from '@tools/video_streaming/ytdlp'
import { formatFilePath, normalizeLanguageCode } from '@sdk/utils'
import { DownloadProgressWidget } from '../widgets/download-progress-widget'
@@ -5,7 +5,7 @@ import type { ActionFunction, ActionParams } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import FfmpegTool from '@sdk/tools/ffmpeg'
import FfmpegTool from '@tools/video_streaming/ffmpeg'
import { formatBytes, formatFilePath } from '@sdk/utils'
export const run: ActionFunction = async function (
@@ -2,11 +2,11 @@ import fs from 'node:fs'
import path from 'node:path'
import type { ActionFunction, ActionParams } from '@sdk/types'
import type { TranscriptionOutput } from '@sdk/tools/transcription-schema'
import type { TranscriptionOutput } from '@tools/music_audio/transcription-schema'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import FfmpegTool from '@sdk/tools/ffmpeg'
import FfmpegTool from '@tools/video_streaming/ffmpeg'
import { formatFilePath } from '@sdk/utils'
interface SpeakerReference {
@@ -259,10 +259,10 @@ export const run: ActionFunction = async function (
* Combines consecutive segments if needed to reach required duration
*/
function findBestSegment(
segments: Array<{ from: number; to: number }>,
segments: Array<{ from: number, to: number }>,
requiredDuration: number,
excludeSegment: { start: number; end: number } | null
): { start: number; end: number } | null {
excludeSegment: { start: number, end: number } | null
): { start: number, end: number } | null {
// Segments are already sorted by time (from), find the earliest usable one
for (let i = 0; i < segments.length; i += 1) {
const currentSegment = segments[i]
@@ -323,13 +323,18 @@ function findBestSegment(
* Find the longest single segment from the available segments
*/
function findLongestSegment(
segments: Array<{ from: number; to: number }>
): { start: number; end: number } | null {
segments: Array<{ from: number, to: number }>
): { start: number, end: number } | null {
if (segments.length === 0) {
return null
}
let longestSegment = segments[0]
const firstSegment = segments[0]
if (!firstSegment) {
return null
}
let longestSegment = firstSegment
let maxDuration = longestSegment.to - longestSegment.from
for (let i = 1; i < segments.length; i += 1) {
@@ -5,7 +5,7 @@ import type { ActionFunction, ActionParams } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import FfmpegTool from '@sdk/tools/ffmpeg'
import FfmpegTool from '@tools/video_streaming/ffmpeg'
import {
formatBytes,
formatFilePath,
@@ -2,12 +2,12 @@ import fs from 'node:fs'
import path from 'node:path'
import type { ActionFunction, ActionParams } from '@sdk/types'
import type { TranscriptionOutput } from '@sdk/tools/transcription-schema'
import type { TranscriptionOutput } from '@tools/music_audio/transcription-schema'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import { Settings } from '@sdk/settings'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import OpenRouterTool from '@sdk/tools/openrouter'
import OpenRouterTool from '@tools/communication/openrouter'
import { formatFilePath } from '@sdk/utils'
interface VideoTranslatorSkillSettings extends Record<string, unknown> {
@@ -6,7 +6,7 @@ import type { ActionFunction } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import Qwen3TtsTool from '@sdk/tools/qwen3_tts'
import Qwen3TtsTool from '@tools/music_audio/qwen3_tts'
import { formatFilePath } from '@sdk/utils'
function sanitizeFileName(value: string): string {
@@ -2,7 +2,7 @@ import type { ActionFunction } from '@sdk/types'
import { leon } from '@sdk/leon'
import { ParamsHelper } from '@sdk/params-helper'
import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'
import OpenMeteoTool from '@sdk/tools/open-meteo'
import OpenMeteoTool from '@tools/weather/openmeteo'
import { WeatherForecastWidget } from '../widgets/weather-forecast-widget'
type Units = 'metric' | 'imperial'
@@ -183,7 +183,7 @@ export class WeatherForecastWidget extends Widget<Params> {
private getWeatherIcon(description: string): string {
const desc = description.toLowerCase()
const iconMap: Array<{ keywords: string[]; icon: string }> = [
const iconMap: Array<{ keywords: string[], icon: string }> = [
{ keywords: ['clear', 'sunny'], icon: 'sun' },
{ keywords: ['cloud', 'overcast'], icon: 'cloud' },
{ keywords: ['drizzle'], icon: 'drizzle' },
@@ -53,7 +53,7 @@ export class PlaygroundTestWidget extends Widget<Params> {
children: 'Shopping List'
}),
new Form({
onSubmit: (data): unknown => {
onSubmit: (data: Record<string, unknown>): unknown => {
return this.runSkillAction('submit_shopping_list', data)
},
children: [
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Business & Finance",
"description": "Tools for business and finance.",
"icon_name": "money-dollar-circle-line",
"context_files": [],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Calendar & Scheduling",
"description": "Tools for calendars and scheduling.",
"icon_name": "calendar-2-line",
"context_files": [],
"tools": []
}
@@ -0,0 +1,3 @@
from .src.python.opencode_tool import OpenCodeTool
__all__ = ["OpenCodeTool"]
@@ -0,0 +1 @@
export { default } from './opencode-tool'
@@ -0,0 +1,68 @@
{{SYSTEM_PROMPT_SECTION}}
{{REPO_SNAPSHOT}}
{{TOOLKIT_INFO}}
# Leon Skill Creation (Concise)
You are generating a Leon skill in **{{LANGUAGE}}**.
## Core Rules
- Use the **{{BRIDGE}}** bridge for all source files.
- Skills live directly under `skills/` (no subfolders).
- All source files use `{{FILE_EXTENSION}}`.
- Validate JSON files against `schemas/skill-schemas/*`.
- Write all required files to disk under the chosen `skills/<name>_skill` folder.
## Required Structure
```
skills/skill_name/
skill.json
locales/en.json
src/
settings.sample.json
settings.json
actions/
widgets/ (optional)
```
## skill.json Rules
- `actions` required, `flow` optional.
- If `flow` exists, only the first action receives user parameters.
- Use `"skill_name:action_name"` for cross-skill flow steps.
- Set `author.name` to `Leon` unless explicitly specified.
## Settings Files
- `src/settings.sample.json` and `src/settings.json` must both exist and start identical.
- Use `{}` if no settings.
## Toolkits (Plan First)
- Choose relevant toolkits from above **before** writing code.
- Use existing tools instead of duplicating functionality.
## leon.answer Basics
{{LEON_ANSWER_BASIC_EXAMPLE}}
## Passing Data Between Actions
{{CONTEXT_DATA_EXAMPLE}}
## Settings Usage
{{SETTINGS_USAGE_EXAMPLE}}
## Widget Rules
- Do not use `Card` as the parent component. The `WidgetWrapper` is already applied by default.
- For icons, use only the icon name without the `ri-` prefix and `-line` suffix. The system automatically completes them to `ri-{icon-name}-line`. For example, use `snow` instead of `ri-snow-line`.
## Action Parameters
{{ACTION_PARAMS_EXAMPLE}}
{{REFERENCE_FILES_SECTION}}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
{{SYSTEM_PROMPT_SECTION}}
{{REPO_SNAPSHOT}}
{{TOOLKIT_INFO}}
# Leon Skill Creation (Concise)
You are generating a Leon skill in **{{LANGUAGE}}**.
## Core Rules
- Use the **{{BRIDGE}}** bridge for all source files.
- Skills live directly under `skills/` (no subfolders).
- All source files use `{{FILE_EXTENSION}}`.
- Validate JSON files against `schemas/skill-schemas/*`.
- Write all required files to disk under the chosen `skills/<name>_skill` folder.
## Required Structure
```
skills/skill_name/
skill.json
locales/en.json
src/
settings.sample.json
settings.json
actions/
widgets/ (optional)
```
## skill.json Rules
- `actions` required, `flow` optional.
- If `flow` exists, only the first action receives user parameters.
- Use `"skill_name:action_name"` for cross-skill flow steps.
- Set `author.name` to `Leon` unless explicitly specified.
## Settings Files
- `src/settings.sample.json` and `src/settings.json` must both exist and start identical.
- Use `{}` if no settings.
## Toolkits (Plan First)
- Choose relevant toolkits from above **before** writing code.
- Use existing tools instead of duplicating functionality.
## leon.answer Basics
{{LEON_ANSWER_BASIC_EXAMPLE}}
## Passing Data Between Actions
{{CONTEXT_DATA_EXAMPLE}}
## Settings Usage
{{SETTINGS_USAGE_EXAMPLE}}
## Widget Rules
- Do not use `Card` as the parent component. The `WidgetWrapper` is already applied by default.
- For icons, use only the icon name without the `ri-` prefix and `-line` suffix. The system automatically completes them to `ri-{icon-name}-line`. For example, use `snow` instead of `ri-snow-line`.
## Action Parameters
{{ACTION_PARAMS_EXAMPLE}}
{{REFERENCE_FILES_SECTION}}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
{
"$schema": "../../../schemas/tool-schemas/tool.json",
"tool_id": "opencode",
"toolkit_id": "coding_development",
"name": "OpenCode",
"description": "An AI-powered coding agent tool that generates skills using multiple LLM providers (Cerebras, MiniMax, Anthropic, OpenAI, Gemini).",
"icon_name": "code-box-line",
"author": {
"name": "Louis Grenard",
"email": "louis@getleon.ai",
"url": "https://twitter.com/grenlouis"
},
"binaries": {
"linux-x86_64": "https://github.com/leon-ai/leon-binaries/releases/download/opencode-v1.14.29/opencode_1.14.29-linux-x86_64.tar.gz",
"linux-aarch64": "https://github.com/leon-ai/leon-binaries/releases/download/opencode-v1.14.29/opencode_1.14.29-linux-aarch64.tar.gz",
"macosx-x86_64": "https://github.com/leon-ai/leon-binaries/releases/download/opencode-v1.14.29/opencode_1.14.29-macosx-x86_64.zip",
"macosx-arm64": "https://github.com/leon-ai/leon-binaries/releases/download/opencode-v1.14.29/opencode_1.14.29-macosx-arm64.zip",
"win-amd64": "https://github.com/leon-ai/leon-binaries/releases/download/opencode-v1.14.29/opencode_1.14.29-win-amd64.zip"
},
"functions": {
"configureProvider": {
"description": "Configure a provider with an API key and optional model.",
"parameters": {
"type": "object",
"properties": {
"provider": {
"type": "string"
},
"apiKey": {
"type": "string"
},
"model": {
"type": "string"
}
},
"required": [
"provider",
"apiKey"
]
}
},
"getConfiguredProviders": {
"description": "List the providers currently configured with API keys.",
"parameters": {
"type": "object",
"properties": {}
}
},
"getAvailableProviders": {
"description": "List providers supported by OpenCode.",
"parameters": {
"type": "object",
"properties": {}
}
},
"getDefaultModel": {
"description": "Get the default model name for a provider.",
"parameters": {
"type": "object",
"properties": {
"provider": {
"type": "string"
}
},
"required": [
"provider"
]
}
},
"generateSkill": {
"description": "Generate a new skill using OpenCode CLI with an agentic loop.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"provider": {
"type": "string"
},
"model": {
"type": "string"
},
"api_key": {
"type": "string"
},
"target_path": {
"type": "string"
},
"context_files": {
"type": "array",
"items": {
"type": "string"
}
},
"system_prompt": {
"type": "string"
},
"bridge": {
"type": "string"
}
},
"required": [
"description",
"provider",
"target_path"
]
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Coding & Development",
"description": "Tools for code generation, development, and automation.",
"icon_name": "code-s-slash-line",
"context_files": [
"ARCHITECTURE.md",
"WORKSPACE_INTELLIGENCE.md",
"LEON_RUNTIME.md",
"HOME.md"
],
"tools": [
"opencode"
]
}
+3
View File
@@ -0,0 +1,3 @@
from .src.python.cerebras_tool import CerebrasTool
__all__ = ["CerebrasTool"]
@@ -0,0 +1,352 @@
import { Tool } from '@sdk/base-tool'
import { ToolkitConfig } from '@sdk/toolkit-config'
import { Network, NetworkError } from '@sdk/network'
// Hardcoded default settings for Cerebras tool
const CEREBRAS_API_KEY: string | null = null
const CEREBRAS_MODEL = 'zai-glm-4.7'
const DEFAULT_SETTINGS: Record<string, unknown> = {
CEREBRAS_API_KEY,
CEREBRAS_MODEL
}
const REQUIRED_SETTINGS = ['CEREBRAS_API_KEY']
interface ChatMessage {
role: string
content: string
}
interface ChatCompletionOptions {
messages: ChatMessage[]
model?: string
temperature?: number
max_tokens?: number
system_prompt?: string
use_structured_output?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json_schema?: Record<string, any>
}
interface CompletionOptions {
prompt: string
model?: string
temperature?: number
max_tokens?: number
system_prompt?: string
use_structured_output?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json_schema?: Record<string, any>
}
interface StructuredCompletionOptions {
prompt: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json_schema: Record<string, any>
model?: string
temperature?: number
max_tokens?: number
system_prompt?: string
}
interface ApiResponse {
success: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any
model_used?: string
error?: string
status_code?: number
}
export default class CerebrasTool extends Tool {
private static readonly TOOLKIT = 'communication'
private readonly config: ReturnType<typeof ToolkitConfig.load>
private api_key: string | null
private model: string
private readonly network: Network
// Popular Cerebras-hosted models (override with full model IDs if needed)
private readonly popular_models = {
'zai-glm-4.7': 'zai-glm-4.7',
'qwen-3-235b-a22b-instruct-2507': 'qwen-3-235b-a22b-instruct-2507',
'qwen-3-32b': 'qwen-3-32b'
}
constructor(apiKey?: string) {
super()
// Load configuration from central toolkits directory
this.config = ToolkitConfig.load(CerebrasTool.TOOLKIT, this.toolName)
const toolSettings = ToolkitConfig.loadToolSettings(
CerebrasTool.TOOLKIT,
this.toolName,
DEFAULT_SETTINGS
)
this.settings = toolSettings
this.requiredSettings = REQUIRED_SETTINGS
this.checkRequiredSettings(this.toolName)
// Priority: skill-provided apiKey > toolkit settings > hardcoded default
this.api_key =
apiKey ||
(this.settings['CEREBRAS_API_KEY'] as string) ||
CEREBRAS_API_KEY
// Load model from toolkit settings or hardcoded default
this.model = (this.settings['CEREBRAS_MODEL'] as string) || CEREBRAS_MODEL
this.network = new Network({ baseURL: 'https://api.cerebras.ai/v1' })
}
get toolName(): string {
return 'cerebras'
}
get toolkit(): string {
return CerebrasTool.TOOLKIT
}
get description(): string {
return this.config['description']
}
/**
* Set the Cerebras API key
*/
setApiKey(apiKey: string): void {
this.api_key = apiKey
}
/**
* Get list of popular available models
*/
getAvailableModels(): string[] {
return Object.keys(this.popular_models)
}
/**
* Convert friendly model name to Cerebras model ID
*/
getModelId(modelName: string): string {
return (
this.popular_models[modelName as keyof typeof this.popular_models] ||
modelName
)
}
/**
* Send a chat completion request to Cerebras
*/
async chatCompletion(options: ChatCompletionOptions): Promise<ApiResponse> {
const {
messages,
model,
temperature = 0.7,
max_tokens,
system_prompt,
use_structured_output = false,
json_schema
} = options
if (!this.api_key) {
return {
success: false,
error: 'Cerebras API key not configured'
}
}
// Use default model if none provided
const finalModel = model || this.model
const modelId = this.getModelId(finalModel)
const requestMessages = []
if (system_prompt) {
requestMessages.push({ role: 'system', content: system_prompt })
}
requestMessages.push(...messages)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload: any = {
model: modelId,
messages: requestMessages,
temperature
}
if (max_tokens) {
payload.max_tokens = max_tokens
}
if (use_structured_output) {
payload.response_format = { type: 'json_object' }
if (json_schema) {
const schemaText = JSON.stringify(json_schema)
const schemaPrompt = `You must return a valid JSON object that matches this schema:\n${schemaText}`
payload.messages = [
{ role: 'system', content: schemaPrompt },
...requestMessages
]
}
}
try {
const response = await this.network.request({
url: '/chat/completions',
method: 'POST',
headers: {
Authorization: `Bearer ${this.api_key}`,
'Content-Type': 'application/json'
},
data: payload
})
return {
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: response.data as any,
model_used: modelId
}
} catch (error: unknown) {
return {
success: false,
error: `Cerebras API error: ${(error as Error).message}`,
status_code:
error instanceof NetworkError ? error.response.statusCode : undefined
}
}
}
/**
* General text completion for any use case
*/
async completion(options: CompletionOptions): Promise<ApiResponse> {
const {
prompt,
model,
temperature = 0.7,
max_tokens,
system_prompt,
use_structured_output = false,
json_schema
} = options
const messages = [{ role: 'user', content: prompt }]
const response = await this.chatCompletion({
messages,
model: model || this.model,
temperature,
max_tokens,
system_prompt,
use_structured_output,
json_schema
})
if (!response.success) {
return response
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (response.data as any).choices[0].message.content
return {
success: true,
data: { content },
model_used: response.model_used
}
} catch (error: unknown) {
return {
success: false,
error: `Failed to extract completion: ${(error as Error).message}`
}
}
}
/**
* Generate structured JSON output using Cerebras structured outputs
*/
async structuredCompletion(
options: StructuredCompletionOptions
): Promise<ApiResponse> {
const {
prompt,
json_schema,
model,
temperature = 0.7,
max_tokens,
system_prompt
} = options
const messages = [{ role: 'user', content: prompt }]
const response = await this.chatCompletion({
messages,
model: model || this.model,
temperature,
max_tokens,
system_prompt,
use_structured_output: true,
json_schema
})
if (!response.success) {
return response
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (response.data as any).choices[0].message.content
const parsedData = JSON.parse(content)
return {
success: true,
data: parsedData,
model_used: response.model_used
}
} catch (error: unknown) {
if (error instanceof SyntaxError) {
return {
success: false,
error: `Failed to parse JSON response: ${error.message}`
}
}
return {
success: false,
error: `Failed to extract completion: ${(error as Error).message}`
}
}
}
/**
* Get list of available models from Cerebras API
*/
async listModels(): Promise<ApiResponse> {
if (!this.api_key) {
return {
success: false,
error: 'Cerebras API key not configured'
}
}
try {
const response = await this.network.request({
url: '/models',
method: 'GET',
headers: {
Authorization: `Bearer ${this.api_key}`
}
})
return {
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { models: (response.data as any).data }
}
} catch (error: unknown) {
return {
success: false,
error: `Failed to fetch models: ${(error as Error).message}`
}
}
}
}
@@ -0,0 +1 @@
export { default } from './cerebras-tool'
@@ -0,0 +1,290 @@
import json
from typing import Dict, Any, Optional, List
from bridges.python.src.sdk.base_tool import BaseTool
from bridges.python.src.sdk.toolkit_config import ToolkitConfig
from bridges.python.src.sdk.network import Network, NetworkError
# Hardcoded default settings for Cerebras tool
CEREBRAS_API_KEY = None
CEREBRAS_MODEL = "zai-glm-4.7"
DEFAULT_SETTINGS = {
"CEREBRAS_API_KEY": CEREBRAS_API_KEY,
"CEREBRAS_MODEL": CEREBRAS_MODEL,
}
REQUIRED_SETTINGS = ["CEREBRAS_API_KEY"]
class CerebrasTool(BaseTool):
"""Cerebras tool for LLM API access (e.g., GLM 4.7)"""
TOOLKIT = "communication"
def __init__(self, api_key: Optional[str] = None):
super().__init__()
self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name)
tool_settings = ToolkitConfig.load_tool_settings(
self.TOOLKIT, self.tool_name, DEFAULT_SETTINGS
)
self.settings = tool_settings
self.required_settings = REQUIRED_SETTINGS
self._check_required_settings(self.tool_name)
# Priority: skill-provided api_key > toolkit settings > hardcoded default
self.api_key = api_key or self.settings.get(
"CEREBRAS_API_KEY", CEREBRAS_API_KEY
)
# Load model settings
self.model = self.settings.get("CEREBRAS_MODEL", CEREBRAS_MODEL)
self.network = Network({"base_url": "https://api.cerebras.ai/v1"})
# Popular Cerebras-hosted models (override with full model IDs if needed)
self.popular_models = {
"zai-glm-4.7": "zai-glm-4.7",
"qwen-3-235b-a22b-instruct-2507": "qwen-3-235b-a22b-instruct-2507",
"qwen-3-32b": "qwen-3-32b",
}
@property
def tool_name(self) -> str:
return "cerebras"
@property
def toolkit(self) -> str:
return self.TOOLKIT
@property
def description(self) -> str:
return self.config["description"]
def set_api_key(self, api_key: str) -> None:
"""Set the Cerebras API key"""
self.api_key = api_key
def get_available_models(self) -> List[str]:
"""Get list of popular available models"""
return list(self.popular_models.keys())
def get_model_id(self, model_name: str) -> str:
"""Convert friendly model name to Cerebras model ID"""
return self.popular_models.get(model_name, model_name)
def chat_completion(
self,
messages: List[Dict[str, str]],
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
use_structured_output: bool = False,
json_schema: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Send a chat completion request to Cerebras
Args:
messages: List of message dictionaries with 'role' and 'content'
model: Model name (friendly name or full model ID)
temperature: Sampling temperature (0-2)
max_tokens: Maximum tokens to generate
system_prompt: System prompt to prepend
use_structured_output: Whether to use structured outputs
json_schema: JSON schema for structured output (required if use_structured_output=True)
Returns:
Dict with response data or error information
"""
if not self.api_key:
return {"success": False, "error": "Cerebras API key not configured"}
# Use default model if none provided
model = model or self.model
model_id = self.get_model_id(model)
request_messages: List[Dict[str, str]] = []
if system_prompt:
request_messages.append({"role": "system", "content": system_prompt})
request_messages.extend(messages)
payload: Dict[str, Any] = {
"model": model_id,
"messages": request_messages,
"temperature": temperature,
}
if max_tokens:
payload["max_tokens"] = max_tokens
if use_structured_output:
payload["response_format"] = {"type": "json_object"}
if json_schema:
schema_text = json.dumps(json_schema)
schema_prompt = (
"You must return a valid JSON object that matches this schema:\n"
f"{schema_text}"
)
payload["messages"] = [
{"role": "system", "content": schema_prompt}
] + request_messages
try:
response = self.network.request(
{
"url": "/chat/completions",
"method": "POST",
"headers": {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
"data": payload,
}
)
return {"success": True, "data": response["data"], "model_used": model_id}
except NetworkError as e:
return {
"success": False,
"error": f"Cerebras API error: {str(e)}",
"status_code": getattr(e.response, "status_code", None),
}
def completion(
self,
prompt: str,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
use_structured_output: bool = False,
json_schema: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
General text completion for any use case
Args:
prompt: Text prompt to complete
model: LLM model to use
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
system_prompt: Optional system prompt
use_structured_output: Whether to use structured outputs
json_schema: JSON schema for structured output
Returns:
Dict with completion result
"""
messages = [{"role": "user", "content": prompt}]
response = self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
system_prompt=system_prompt,
use_structured_output=use_structured_output,
json_schema=json_schema,
)
if not response["success"]:
return response
try:
content = response["data"]["choices"][0]["message"]["content"]
return {
"success": True,
"content": content,
"model_used": response["model_used"],
}
except (KeyError, IndexError) as e:
return {
"success": False,
"error": f"Failed to extract completion: {str(e)}",
}
def structured_completion(
self,
prompt: str,
json_schema: Dict[str, Any],
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate structured JSON output using Cerebras structured outputs
Args:
prompt: Text prompt to complete
json_schema: JSON schema defining the required output structure
model: LLM model to use
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
system_prompt: Optional system prompt
Returns:
Dict with parsed JSON result or error
"""
messages = [{"role": "user", "content": prompt}]
response = self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
system_prompt=system_prompt,
use_structured_output=True,
json_schema=json_schema,
)
if not response["success"]:
return response
try:
content = response["data"]["choices"][0]["message"]["content"]
parsed_data = json.loads(content)
return {
"success": True,
"data": parsed_data,
"model_used": response["model_used"],
}
except (KeyError, IndexError) as e:
return {
"success": False,
"error": f"Failed to extract completion: {str(e)}",
}
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Failed to parse JSON response: {str(e)}",
}
def list_models(self) -> Dict[str, Any]:
"""
Get list of available models from Cerebras API
Returns:
Dict with models list or error
"""
if not self.api_key:
return {"success": False, "error": "Cerebras API key not configured"}
try:
response = self.network.request(
{
"url": "/models",
"method": "GET",
"headers": {"Authorization": f"Bearer {self.api_key}"},
}
)
return {
"success": True,
"models": response["data"].get("data", response["data"]),
}
except NetworkError as e:
return {"success": False, "error": f"Failed to fetch models: {str(e)}"}
+161
View File
@@ -0,0 +1,161 @@
{
"$schema": "../../../schemas/tool-schemas/tool.json",
"tool_id": "cerebras",
"toolkit_id": "communication",
"name": "Cerebras",
"description": "A tool for interacting with Cerebras LLM APIs (e.g., GLM 4.7).",
"author": {
"name": "Louis Grenard",
"email": "louis@getleon.ai",
"url": "https://twitter.com/grenlouis"
},
"functions": {
"chatCompletion": {
"description": "Generate a chat completion using the Cerebras API.",
"parameters": {
"type": "object",
"properties": {
"options": {
"type": "object",
"properties": {
"messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"role": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": [
"role",
"content"
],
"additionalProperties": false
}
},
"model": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"system_prompt": {
"type": "string"
},
"use_structured_output": {
"type": "boolean"
},
"json_schema": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"messages"
],
"additionalProperties": false
}
},
"required": [
"options"
]
}
},
"completion": {
"description": "Generate a completion using the Cerebras API.",
"parameters": {
"type": "object",
"properties": {
"options": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"model": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"system_prompt": {
"type": "string"
},
"use_structured_output": {
"type": "boolean"
},
"json_schema": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"prompt"
],
"additionalProperties": false
}
},
"required": [
"options"
]
}
},
"structuredCompletion": {
"description": "Generate a structured completion using a JSON schema.",
"parameters": {
"type": "object",
"properties": {
"options": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"json_schema": {
"type": "object",
"additionalProperties": true
},
"model": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"system_prompt": {
"type": "string"
}
},
"required": [
"prompt",
"json_schema"
],
"additionalProperties": false
}
},
"required": [
"options"
]
}
},
"listModels": {
"description": "List available Cerebras models.",
"parameters": {
"type": "object",
"properties": {}
}
}
}
}
@@ -0,0 +1,3 @@
from .src.python.inference_tool import InferenceTool
__all__ = ["InferenceTool"]
@@ -0,0 +1 @@
export { default } from './inference-tool'
@@ -0,0 +1,97 @@
import { Tool } from '@sdk/base-tool'
import { ToolkitConfig } from '@sdk/toolkit-config'
import { Network } from '@sdk/network'
interface CompletionOptions {
prompt: string
system_prompt?: string
temperature?: number
max_tokens?: number
thought_tokens_budget?: number
disable_thinking?: boolean
reasoning_mode?: 'off' | 'guarded' | 'on'
track_provider_errors?: boolean
}
interface StructuredCompletionOptions extends CompletionOptions {
json_schema: Record<string, unknown>
}
interface InferenceResponse {
success: boolean
output?: unknown
reasoning?: string
usedInputTokens?: number
usedOutputTokens?: number
generationDurationMs?: number
providerDecodeDurationMs?: number
providerTokensPerSecond?: number
error?: string
}
export default class InferenceTool extends Tool {
private static readonly TOOLKIT = 'communication'
private readonly config: ReturnType<typeof ToolkitConfig.load>
private readonly network: Network
constructor() {
super()
this.config = ToolkitConfig.load(InferenceTool.TOOLKIT, this.toolName)
this.network = new Network({
baseURL: `${process.env['LEON_HOST']}:${process.env['LEON_PORT']}/api/v1`
})
}
get toolName(): string {
return 'inference'
}
get toolkit(): string {
return InferenceTool.TOOLKIT
}
get description(): string {
return this.config['description']
}
async completion(options: CompletionOptions): Promise<InferenceResponse> {
const response = await this.network.request<InferenceResponse>({
url: '/inference',
method: 'POST',
data: {
prompt: options.prompt,
systemPrompt: options.system_prompt,
temperature: options.temperature,
maxTokens: options.max_tokens,
thoughtTokensBudget: options.thought_tokens_budget,
disableThinking: options.disable_thinking,
reasoningMode: options.reasoning_mode,
trackProviderErrors: options.track_provider_errors
}
})
return response.data
}
async structuredCompletion(
options: StructuredCompletionOptions
): Promise<InferenceResponse> {
const response = await this.network.request<InferenceResponse>({
url: '/inference',
method: 'POST',
data: {
prompt: options.prompt,
systemPrompt: options.system_prompt,
temperature: options.temperature,
maxTokens: options.max_tokens,
thoughtTokensBudget: options.thought_tokens_budget,
jsonSchema: options.json_schema,
disableThinking: options.disable_thinking,
reasoningMode: options.reasoning_mode,
trackProviderErrors: options.track_provider_errors
}
})
return response.data
}
}
@@ -0,0 +1,93 @@
import os
from typing import Any, Dict, Optional, Literal
from bridges.python.src.sdk.base_tool import BaseTool
from bridges.python.src.sdk.network import Network
from bridges.python.src.sdk.toolkit_config import ToolkitConfig
class InferenceTool(BaseTool):
TOOLKIT = "communication"
def __init__(self):
super().__init__()
self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name)
self.network = Network(
{
"base_url": f"{os.environ.get('LEON_HOST')}:{os.environ.get('LEON_PORT')}/api/v1"
}
)
@property
def tool_name(self) -> str:
return "inference"
@property
def toolkit(self) -> str:
return self.TOOLKIT
@property
def description(self) -> str:
return self.config.get("description", "")
def completion(
self,
prompt: str,
system_prompt: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
thought_tokens_budget: Optional[int] = None,
disable_thinking: Optional[bool] = None,
reasoning_mode: Optional[Literal["off", "guarded", "on"]] = None,
track_provider_errors: Optional[bool] = None,
) -> Dict[str, Any]:
response = self.network.request(
{
"url": "/inference",
"method": "POST",
"data": {
"prompt": prompt,
"systemPrompt": system_prompt,
"temperature": temperature,
"maxTokens": max_tokens,
"thoughtTokensBudget": thought_tokens_budget,
"disableThinking": disable_thinking,
"reasoningMode": reasoning_mode,
"trackProviderErrors": track_provider_errors,
},
}
)
return response["data"]
def structured_completion(
self,
prompt: str,
json_schema: Dict[str, Any],
system_prompt: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
thought_tokens_budget: Optional[int] = None,
disable_thinking: Optional[bool] = None,
reasoning_mode: Optional[Literal["off", "guarded", "on"]] = None,
track_provider_errors: Optional[bool] = None,
) -> Dict[str, Any]:
response = self.network.request(
{
"url": "/inference",
"method": "POST",
"data": {
"prompt": prompt,
"systemPrompt": system_prompt,
"temperature": temperature,
"maxTokens": max_tokens,
"thoughtTokensBudget": thought_tokens_budget,
"jsonSchema": json_schema,
"disableThinking": disable_thinking,
"reasoningMode": reasoning_mode,
"trackProviderErrors": track_provider_errors,
},
}
)
return response["data"]
+101
View File
@@ -0,0 +1,101 @@
{
"$schema": "../../../schemas/tool-schemas/tool.json",
"tool_id": "inference",
"toolkit_id": "communication",
"name": "Inference",
"description": "A generic Leon workflow inference tool backed by the active workflow LLM provider.",
"author": {
"name": "Louis Grenard",
"email": "louis@getleon.ai",
"url": "https://twitter.com/grenlouis"
},
"functions": {
"completion": {
"description": "Generate a workflow inference completion.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"options": {
"type": "object",
"properties": {
"system_prompt": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"thought_tokens_budget": {
"type": "number"
},
"disable_thinking": {
"type": "boolean"
},
"reasoning_mode": {
"type": "string"
},
"track_provider_errors": {
"type": "boolean"
}
},
"additionalProperties": false
}
},
"required": [
"prompt"
]
}
},
"structuredCompletion": {
"description": "Generate a structured workflow inference completion using a JSON schema.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"json_schema": {
"type": "object",
"additionalProperties": true
},
"options": {
"type": "object",
"properties": {
"system_prompt": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"thought_tokens_budget": {
"type": "number"
},
"disable_thinking": {
"type": "boolean"
},
"reasoning_mode": {
"type": "string"
},
"track_provider_errors": {
"type": "boolean"
}
},
"additionalProperties": false
}
},
"required": [
"prompt",
"json_schema"
]
}
}
}
}
@@ -0,0 +1,3 @@
from .src.python.openrouter_tool import OpenRouterTool
__all__ = ["OpenRouterTool"]
@@ -0,0 +1 @@
export { default } from './openrouter-tool'
@@ -0,0 +1,340 @@
import { Tool } from '@sdk/base-tool'
import { ToolkitConfig } from '@sdk/toolkit-config'
import { Network, NetworkError } from '@sdk/network'
// Hardcoded default settings for OpenRouter tool
const OPENROUTER_API_KEY: string | null = null
const OPENROUTER_MODEL = 'google/gemini-3-flash-preview'
const DEFAULT_SETTINGS: Record<string, unknown> = {
OPENROUTER_API_KEY,
OPENROUTER_MODEL
}
const REQUIRED_SETTINGS = ['OPENROUTER_API_KEY']
interface ChatMessage {
role: string
content: string
}
interface ChatCompletionOptions {
messages: ChatMessage[]
model?: string
temperature?: number
max_tokens?: number
system_prompt?: string
use_structured_output?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json_schema?: Record<string, any>
}
interface CompletionOptions {
prompt: string
model?: string
temperature?: number
max_tokens?: number
system_prompt?: string
use_structured_output?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json_schema?: Record<string, any>
}
interface StructuredCompletionOptions {
prompt: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json_schema: Record<string, any>
model?: string
temperature?: number
max_tokens?: number
system_prompt?: string
}
interface ApiResponse {
success: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any
model_used?: string
error?: string
status_code?: number
}
export default class OpenRouterTool extends Tool {
private static readonly TOOLKIT = 'communication'
private readonly config: ReturnType<typeof ToolkitConfig.load>
private api_key: string | null
private model: string
private readonly network: Network
constructor(apiKey?: string) {
super()
// Load configuration from central toolkits directory
this.config = ToolkitConfig.load(OpenRouterTool.TOOLKIT, this.toolName)
const toolSettings = ToolkitConfig.loadToolSettings(
OpenRouterTool.TOOLKIT,
this.toolName,
DEFAULT_SETTINGS
)
this.settings = toolSettings
this.requiredSettings = REQUIRED_SETTINGS
this.checkRequiredSettings(this.toolName)
// Priority: skill-provided apiKey > toolkit settings > hardcoded default
this.api_key =
apiKey ||
(this.settings['OPENROUTER_API_KEY'] as string) ||
OPENROUTER_API_KEY
// Load model from toolkit settings or hardcoded default
this.model =
(this.settings['OPENROUTER_MODEL'] as string) || OPENROUTER_MODEL
this.network = new Network({ baseURL: 'https://openrouter.ai/api' })
}
get toolName(): string {
return 'openrouter'
}
get toolkit(): string {
return OpenRouterTool.TOOLKIT
}
get description(): string {
return this.config['description']
}
/**
* Set the OpenRouter API key
*/
setApiKey(apiKey: string): void {
this.api_key = apiKey
}
/**
* Send a chat completion request to OpenRouter
*/
async chatCompletion(options: ChatCompletionOptions): Promise<ApiResponse> {
const {
messages,
model,
temperature = 0.7,
max_tokens,
system_prompt,
use_structured_output = false,
json_schema
} = options
if (!this.api_key) {
return {
success: false,
error: 'OpenRouter API key not configured'
}
}
// Use default model if none provided
const finalModel = model || this.model
// Prepare messages with system prompt if provided
const requestMessages = []
if (system_prompt) {
requestMessages.push({ role: 'system', content: system_prompt })
}
requestMessages.push(...messages)
// Prepare request payload
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload: any = {
model: finalModel,
messages: requestMessages,
temperature
}
if (max_tokens) {
payload.max_tokens = max_tokens
}
// Add structured output configuration if requested
if (use_structured_output && json_schema) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: json_schema['name'] || 'response',
strict: true,
schema: json_schema['schema']
}
}
}
try {
const response = await this.network.request({
url: '/v1/chat/completions',
method: 'POST',
headers: {
Authorization: `Bearer ${this.api_key}`,
'Content-Type': 'application/json'
},
data: payload
})
return {
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: response.data as any,
model_used: finalModel
}
} catch (error: unknown) {
return {
success: false,
error: `OpenRouter API error: ${(error as Error).message}`,
status_code:
error instanceof NetworkError ? error.response.statusCode : undefined
}
}
}
/**
* General text completion for any use case
*/
async completion(options: CompletionOptions): Promise<ApiResponse> {
const {
prompt,
model,
temperature = 0.7,
max_tokens,
system_prompt,
use_structured_output = false,
json_schema
} = options
const messages = [{ role: 'user', content: prompt }]
const response = await this.chatCompletion({
messages,
model: model || this.model,
temperature,
max_tokens,
system_prompt,
use_structured_output,
json_schema
})
if (!response.success) {
return response
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (response.data as any).choices[0].message.content
return {
success: true,
data: { content },
model_used: response.model_used
}
} catch (error: unknown) {
return {
success: false,
error: `Failed to extract completion: ${(error as Error).message}`
}
}
}
/**
* Generate structured JSON output using OpenRouter's structured outputs feature
*/
async structuredCompletion(
options: StructuredCompletionOptions
): Promise<ApiResponse> {
const {
prompt,
json_schema,
model,
temperature = 0.7,
max_tokens,
system_prompt
} = options
const messages = [{ role: 'user', content: prompt }]
const response = await this.chatCompletion({
messages,
model: model || this.model,
temperature,
max_tokens,
system_prompt,
use_structured_output: true,
json_schema
})
if (!response.success) {
return response
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (response.data as any).choices[0].message.content
const parsedData =
typeof content === 'string' ? JSON.parse(content) : content
return {
success: true,
data: parsedData,
model_used: response.model_used
}
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (response.data as any).choices[0]?.message?.content
if (error instanceof SyntaxError) {
// Show raw response preview to help debug JSON parsing errors
const preview =
typeof content === 'string'
? content.substring(0, 500)
: JSON.stringify(content ?? 'null').substring(0, 500)
return {
success: false,
error: `Failed to parse JSON response: ${error.message}. Response preview: ${preview}`
}
} else {
return {
success: false,
error: `Failed to extract completion: ${(error as Error).message}`
}
}
}
}
/**
* Get list of available models from OpenRouter API
*/
async listModels(): Promise<ApiResponse> {
if (!this.api_key) {
return {
success: false,
error: 'OpenRouter API key not configured'
}
}
try {
const response = await this.network.request({
url: '/v1/models',
method: 'GET',
headers: {
Authorization: `Bearer ${this.api_key}`
}
})
return {
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { models: (response.data as any).data }
}
} catch (error: unknown) {
return {
success: false,
error: `Failed to fetch models: ${(error as Error).message}`
}
}
}
}
@@ -0,0 +1,272 @@
import json
from typing import Dict, Any, Optional, List
from bridges.python.src.sdk.base_tool import BaseTool
from bridges.python.src.sdk.toolkit_config import ToolkitConfig
from bridges.python.src.sdk.network import Network, NetworkError
# Hardcoded default settings for OpenRouter tool
OPENROUTER_API_KEY = None
OPENROUTER_MODEL = "google/gemini-3-flash-preview"
DEFAULT_SETTINGS = {
"OPENROUTER_API_KEY": OPENROUTER_API_KEY,
"OPENROUTER_MODEL": OPENROUTER_MODEL,
}
REQUIRED_SETTINGS = ["OPENROUTER_API_KEY"]
class OpenRouterTool(BaseTool):
"""OpenRouter tool for unified LLM API access across all skills"""
TOOLKIT = "communication"
def __init__(self, api_key: Optional[str] = None):
super().__init__()
self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name)
tool_settings = ToolkitConfig.load_tool_settings(
self.TOOLKIT, self.tool_name, DEFAULT_SETTINGS
)
self.settings = tool_settings
self.required_settings = REQUIRED_SETTINGS
self._check_required_settings(self.tool_name)
# Priority: skill-provided api_key > toolkit settings > hardcoded default
self.api_key = api_key or self.settings.get(
"OPENROUTER_API_KEY", OPENROUTER_API_KEY
)
# Load model settings
self.model = self.settings.get("OPENROUTER_MODEL", OPENROUTER_MODEL)
self.network = Network({"base_url": "https://openrouter.ai/api"})
@property
def tool_name(self) -> str:
return "openrouter"
@property
def toolkit(self) -> str:
return self.TOOLKIT
@property
def description(self) -> str:
return self.config["description"]
def set_api_key(self, api_key: str) -> None:
"""Set the OpenRouter API key"""
self.api_key = api_key
def chat_completion(
self,
messages: List[Dict[str, str]],
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
use_structured_output: bool = False,
json_schema: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Send a chat completion request to OpenRouter
Args:
messages: List of message dictionaries with 'role' and 'content'
model: Model ID (full OpenRouter model ID, e.g. 'google/gemini-3-flash-preview')
temperature: Sampling temperature (0-2)
max_tokens: Maximum tokens to generate
system_prompt: System prompt to prepend
use_structured_output: Whether to use OpenRouter's structured outputs
json_schema: JSON schema for structured output (required if use_structured_output=True)
Returns:
Dict with response data or error information
"""
if not self.api_key:
return {"success": False, "error": "OpenRouter API key not configured"}
# Use default model if none provided
model = model or self.model
# Prepare messages with system prompt if provided
request_messages = []
if system_prompt:
request_messages.append({"role": "system", "content": system_prompt})
request_messages.extend(messages)
# Prepare request payload
payload = {
"model": model,
"messages": request_messages,
"temperature": temperature,
}
if max_tokens:
payload["max_tokens"] = max_tokens
# Add structured output configuration if requested
if use_structured_output and json_schema:
payload["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": json_schema.get("name", "response"),
"strict": True,
"schema": json_schema["schema"],
},
}
try:
response = self.network.request(
{
"url": "/v1/chat/completions",
"method": "POST",
"headers": {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
"data": payload,
}
)
return {"success": True, "data": response["data"], "model_used": model}
except NetworkError as e:
return {
"success": False,
"error": f"OpenRouter API error: {str(e)}",
"status_code": getattr(e.response, "status_code", None),
}
def completion(
self,
prompt: str,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
use_structured_output: bool = False,
json_schema: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
General text completion for any use case
Args:
prompt: Text prompt to complete
model: Model ID (full OpenRouter model ID)
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
system_prompt: Optional system prompt
use_structured_output: Whether to use structured outputs
json_schema: JSON schema for structured output
Returns:
Dict with completion result
"""
messages = [{"role": "user", "content": prompt}]
response = self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
system_prompt=system_prompt,
use_structured_output=use_structured_output,
json_schema=json_schema,
)
if not response["success"]:
return response
try:
content = response["data"]["choices"][0]["message"]["content"]
return {
"success": True,
"content": content,
"model_used": response["model_used"],
}
except (KeyError, IndexError) as e:
return {
"success": False,
"error": f"Failed to extract completion: {str(e)}",
}
def structured_completion(
self,
prompt: str,
json_schema: Dict[str, Any],
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate structured JSON output using OpenRouter's structured outputs feature
Args:
prompt: Text prompt to complete
json_schema: JSON schema defining the required output structure
model: Model ID (full OpenRouter model ID)
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
system_prompt: Optional system prompt
Returns:
Dict with parsed JSON result or error
"""
messages = [{"role": "user", "content": prompt}]
response = self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
system_prompt=system_prompt,
use_structured_output=True,
json_schema=json_schema,
)
if not response["success"]:
return response
try:
content = response["data"]["choices"][0]["message"]["content"]
# With structured outputs, content is already valid JSON
parsed_data = json.loads(content)
return {
"success": True,
"data": parsed_data,
"model_used": response["model_used"],
}
except (KeyError, IndexError) as e:
return {
"success": False,
"error": f"Failed to extract completion: {str(e)}",
}
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Failed to parse JSON response: {str(e)}",
}
def list_models(self) -> Dict[str, Any]:
"""
Get list of available models from OpenRouter API
Returns:
Dict with models list or error
"""
if not self.api_key:
return {"success": False, "error": "OpenRouter API key not configured"}
try:
response = self.network.request(
{
"url": "/v1/models",
"method": "GET",
"headers": {"Authorization": f"Bearer {self.api_key}"},
}
)
return {"success": True, "models": response["data"]["data"]}
except NetworkError as e:
return {"success": False, "error": f"Failed to fetch models: {str(e)}"}
+162
View File
@@ -0,0 +1,162 @@
{
"$schema": "../../../schemas/tool-schemas/tool.json",
"tool_id": "openrouter",
"toolkit_id": "communication",
"name": "OpenRouter",
"description": "A tool for interacting with various LLMs through the OpenRouter API gateway.",
"icon_name": "route-line",
"author": {
"name": "Louis Grenard",
"email": "louis@getleon.ai",
"url": "https://twitter.com/grenlouis"
},
"functions": {
"chatCompletion": {
"description": "Generate a chat completion using OpenRouter.",
"parameters": {
"type": "object",
"properties": {
"options": {
"type": "object",
"properties": {
"messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"role": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": [
"role",
"content"
],
"additionalProperties": false
}
},
"model": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"system_prompt": {
"type": "string"
},
"use_structured_output": {
"type": "boolean"
},
"json_schema": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"messages"
],
"additionalProperties": false
}
},
"required": [
"options"
]
}
},
"completion": {
"description": "Generate a completion using OpenRouter.",
"parameters": {
"type": "object",
"properties": {
"options": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"model": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"system_prompt": {
"type": "string"
},
"use_structured_output": {
"type": "boolean"
},
"json_schema": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"prompt"
],
"additionalProperties": false
}
},
"required": [
"options"
]
}
},
"structuredCompletion": {
"description": "Generate a structured completion using a JSON schema.",
"parameters": {
"type": "object",
"properties": {
"options": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"json_schema": {
"type": "object",
"additionalProperties": true
},
"model": {
"type": "string"
},
"temperature": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"system_prompt": {
"type": "string"
}
},
"required": [
"prompt",
"json_schema"
],
"additionalProperties": false
}
},
"required": [
"options"
]
}
},
"listModels": {
"description": "List available OpenRouter models.",
"parameters": {
"type": "object",
"properties": {}
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Communication",
"description": "Tools for communication and language model interactions.",
"icon_name": "chat-3-line",
"context_files": [
"LEON.md",
"ARCHITECTURE.md",
"MEDIA_PROFILE.md"
],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Dialog",
"description": "Tools for dialog and conversation handling.",
"icon_name": "discuss-line",
"context_files": [],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "File System",
"description": "Tools for file system operations.",
"icon_name": "folders-line",
"context_files": [],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Food & Drink",
"description": "Tools for food and drink queries.",
"icon_name": "restaurant-2-line",
"context_files": [],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Games",
"description": "Tools for games and entertainment.",
"icon_name": "gamepad-line",
"context_files": [],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Health & Fitness",
"description": "Tools for health and fitness information.",
"icon_name": "heart-pulse-line",
"context_files": [],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Media Generation",
"description": "Tools for media generation and creative workflows.",
"icon_name": "sparkling-2-line",
"context_files": [],
"tools": []
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Movies & TV",
"description": "Tools for movies and TV information.",
"icon_name": "movie-2-line",
"context_files": [],
"tools": []
}

Some files were not shown because too many files have changed in this diff Show More