Add support for multiple providers
This commit is contained in:
@@ -1,30 +1,49 @@
|
|||||||
shutdown_timeout: 30s
|
shutdown_timeout: 30s
|
||||||
|
|
||||||
server:
|
address: ":8080"
|
||||||
address: ":8080"
|
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: "http://localhost:8178"
|
url: "http://localhost:8178"
|
||||||
|
|
||||||
llm:
|
|
||||||
url: "http://localhost:8081/v1"
|
|
||||||
key: ${ODIDERE_LLM_KEY}
|
|
||||||
model: "default"
|
|
||||||
system_message: "You are a helpful voice assistant. Be concise."
|
|
||||||
timeout: "5m"
|
|
||||||
|
|
||||||
tts:
|
tts:
|
||||||
url: "http://localhost:8880"
|
url: "http://localhost:8880"
|
||||||
voice: "af_heart"
|
voice: "af_heart"
|
||||||
timeout: "60s"
|
timeout: "60s"
|
||||||
|
|
||||||
|
default_model:
|
||||||
|
provider: "llama.cpp"
|
||||||
|
model: "qwen3.6-27b"
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: "llama.cpp"
|
||||||
|
key: ${LLM_API_KEY}
|
||||||
|
url: "http://localhost:8081/v1"
|
||||||
|
timeout: "5m"
|
||||||
|
|
||||||
|
- name: "openrouter"
|
||||||
|
url: "https://openrouter.ai/api/v1"
|
||||||
|
key: "${OPENROUTER_KEY}"
|
||||||
|
timeout: "5m"
|
||||||
|
models:
|
||||||
|
- "~anthropic/claude-opus-latest:exacto"
|
||||||
|
- "~google/gemini-pro-latest:exacto"
|
||||||
|
- "~openai/gpt-latest:exacto"
|
||||||
|
- "deepseek/deepseek-v4-pro:exacto"
|
||||||
|
- "deepseek/deepseek-v4-flash:exacto"
|
||||||
|
- "minimax/minimax-m3:exacto"
|
||||||
|
- "mistralai/mistral-medium-3-5:exacto"
|
||||||
|
- "moonshotai/kimi-k2.6:exacto"
|
||||||
|
- "moonshotai/kimi-k2.7-code:exacto"
|
||||||
|
- "qwen/qwen3.6-27b:exacto"
|
||||||
|
- "z-ai/glm-5.1:exacto"
|
||||||
|
|
||||||
tools:
|
tools:
|
||||||
- name: get_weather
|
- name: get_weather
|
||||||
description: "Get current weather for a location"
|
description: "Get current weather for a location"
|
||||||
command: "curl"
|
command: "curl"
|
||||||
arguments:
|
arguments:
|
||||||
- "-s"
|
- "-s"
|
||||||
- "https://wttr.in/{{.location}}?format=j1"
|
- "https://wttr.in/{{ .location }}?format=j1"
|
||||||
parameters:
|
parameters:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -34,11 +53,51 @@ tools:
|
|||||||
required:
|
required:
|
||||||
- location
|
- location
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
|
- name: sandbox_run
|
||||||
|
description: |
|
||||||
|
Execute a shell command inside a bubblewrap sandbox. The sandbox has
|
||||||
|
read-write access to the current directory, read-only access to system
|
||||||
|
binaries, no network access, no SSH keys, isolated /tmp, and process
|
||||||
|
isolation. Use this for all file operations (cat, grep, find, mkdir,
|
||||||
|
sed, tee, patch), building, testing, and any command execution that
|
||||||
|
does not require network access.
|
||||||
|
command: "sandbox_run"
|
||||||
|
arguments:
|
||||||
|
- "{{ .command }}"
|
||||||
|
parameters:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
command:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
"The complete shell command to execute inside the sandbox."
|
||||||
|
required:
|
||||||
|
- command
|
||||||
|
timeout: "30s"
|
||||||
|
- name: host_run
|
||||||
|
description: |
|
||||||
|
Execute a shell command on the host system with no sandbox. Use only
|
||||||
|
when you need network access (curl, SSH, git clone), system-level
|
||||||
|
changes (package installs, service management), or access to files
|
||||||
|
outside the current directory. Prefer sandbox_run for all other command
|
||||||
|
execution.
|
||||||
|
command: "bash"
|
||||||
|
arguments:
|
||||||
|
- "-c"
|
||||||
|
- "{{ .command }}"
|
||||||
|
parameters:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
command:
|
||||||
|
type: string
|
||||||
|
description: "The complete shell command to execute on the host"
|
||||||
|
required:
|
||||||
|
- command
|
||||||
|
timeout: "60s"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
- name: "daily_summary"
|
- name: "daily_summary"
|
||||||
schedule: "0 9 * * *"
|
schedule: "0 9 * * *"
|
||||||
enabled: true
|
|
||||||
system_message: "You are an assistant."
|
system_message: "You are an assistant."
|
||||||
task: "Report the uptime on your host."
|
task: "Report the uptime on your host."
|
||||||
timeout: "3m"
|
timeout: "3m"
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.chimeric.al/chimerical/odidere/internal/job"
|
|
||||||
"code.chimeric.al/chimerical/odidere/internal/llm"
|
|
||||||
"code.chimeric.al/chimerical/odidere/internal/tool"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +27,9 @@ func (cfg TTS) Validate() error {
|
|||||||
if cfg.URL == "" {
|
if cfg.URL == "" {
|
||||||
return fmt.Errorf("missing URL")
|
return fmt.Errorf("missing URL")
|
||||||
}
|
}
|
||||||
|
if _, err := url.Parse(cfg.URL); err != nil {
|
||||||
|
return fmt.Errorf("invalid URL %q: %w", cfg.URL, err)
|
||||||
|
}
|
||||||
if cfg.Voice == "" {
|
if cfg.Voice == "" {
|
||||||
return fmt.Errorf("missing voice")
|
return fmt.Errorf("missing voice")
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,6 @@ func (cfg TTS) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// STT holds the configuration for the frontend-facing speech-to-text server.
|
// STT holds the configuration for the frontend-facing speech-to-text server.
|
||||||
// The URL is passed to the browser via a meta tag; the browser calls
|
|
||||||
// whisper-server directly.
|
|
||||||
type STT struct {
|
type STT struct {
|
||||||
// URL is the base URL of the whisper-server.
|
// URL is the base URL of the whisper-server.
|
||||||
// For example, "http://localhost:8178".
|
// For example, "http://localhost:8178".
|
||||||
@@ -54,6 +52,118 @@ func (cfg STT) Validate() error {
|
|||||||
if cfg.URL == "" {
|
if cfg.URL == "" {
|
||||||
return fmt.Errorf("missing URL")
|
return fmt.Errorf("missing URL")
|
||||||
}
|
}
|
||||||
|
if _, err := url.Parse(cfg.URL); err != nil {
|
||||||
|
return fmt.Errorf("invalid URL %q: %w", cfg.URL, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderConfig holds the configuration for a single LLM provider.
|
||||||
|
type ProviderConfig struct {
|
||||||
|
// Name uniquely identifies this provider (e.g., "llama.cpp", "openrouter").
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
// URL is the base URL of the OpenAI-compatible API endpoint.
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
// Key is the API key for authentication.
|
||||||
|
Key string `yaml:"key"`
|
||||||
|
// Timeout is the maximum duration for a query (e.g., "5m").
|
||||||
|
// Defaults to 5 minutes if empty.
|
||||||
|
Timeout string `yaml:"timeout"`
|
||||||
|
// Models is an optional whitelist of model IDs for this provider.
|
||||||
|
Models []string `yaml:"models,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg ProviderConfig) Validate() error {
|
||||||
|
if cfg.Name == "" {
|
||||||
|
return fmt.Errorf("missing name")
|
||||||
|
}
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return fmt.Errorf("missing URL")
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(cfg.URL); err != nil {
|
||||||
|
return fmt.Errorf("invalid URL %q: %w", cfg.URL, err)
|
||||||
|
}
|
||||||
|
if cfg.Timeout != "" {
|
||||||
|
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||||
|
return fmt.Errorf("invalid timeout: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelConfig specifies the provider and model to use.
|
||||||
|
type ModelConfig struct {
|
||||||
|
// Provider is the provider name (e.g., "llama.cpp").
|
||||||
|
Provider string `yaml:"provider,omitempty"`
|
||||||
|
// Model is the model ID within that provider (e.g., "qwen3.6-27b").
|
||||||
|
Model string `yaml:"model,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobConfig holds the user-facing configuration for a job.
|
||||||
|
type JobConfig struct {
|
||||||
|
// MaxIterations caps the number of agent loop iterations.
|
||||||
|
MaxIterations int `yaml:"max_iterations,omitempty"`
|
||||||
|
// Name uniquely identifies the job within the config.
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
// Model is the optional model for this job.
|
||||||
|
Model ModelConfig `yaml:"model,omitempty"`
|
||||||
|
// Schedule is a cron expression (e.g., "0 9 * * *").
|
||||||
|
Schedule string `yaml:"schedule"`
|
||||||
|
// SystemMessage overrides the global system message for this job.
|
||||||
|
SystemMessage string `yaml:"system_message,omitempty"`
|
||||||
|
// Task is the user-level instruction to give the LLM.
|
||||||
|
Task string `yaml:"task"`
|
||||||
|
// Timeout limits the job execution time (e.g., "10m").
|
||||||
|
Timeout string `yaml:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg JobConfig) Validate() error {
|
||||||
|
if cfg.Name == "" {
|
||||||
|
return fmt.Errorf("missing name")
|
||||||
|
}
|
||||||
|
if cfg.Task == "" {
|
||||||
|
return fmt.Errorf("missing task")
|
||||||
|
}
|
||||||
|
if cfg.Schedule == "" {
|
||||||
|
return fmt.Errorf("missing schedule")
|
||||||
|
}
|
||||||
|
if cfg.MaxIterations < 0 {
|
||||||
|
return fmt.Errorf("max_iterations must be non-negative")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolConfig represents an external tool definition.
|
||||||
|
type ToolConfig struct {
|
||||||
|
// Name uniquely identifies the tool within a registry.
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
// Description explains the tool's purpose for the LLM.
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
// Command is the executable path or name.
|
||||||
|
Command string `yaml:"command"`
|
||||||
|
// Arguments are Go templates expanded with LLM-provided parameters.
|
||||||
|
Arguments []string `yaml:"arguments"`
|
||||||
|
// Parameters is a JSON Schema describing expected input from the LLM.
|
||||||
|
Parameters map[string]any `yaml:"parameters"`
|
||||||
|
// Timeout limits execution time (e.g., "30s", "5m").
|
||||||
|
Timeout string `yaml:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg ToolConfig) Validate() error {
|
||||||
|
if cfg.Name == "" {
|
||||||
|
return fmt.Errorf("missing name")
|
||||||
|
}
|
||||||
|
if cfg.Description == "" {
|
||||||
|
return fmt.Errorf("missing description")
|
||||||
|
}
|
||||||
|
if cfg.Command == "" {
|
||||||
|
return fmt.Errorf("missing command")
|
||||||
|
}
|
||||||
|
if cfg.Timeout != "" {
|
||||||
|
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||||
|
return fmt.Errorf("invalid timeout: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,27 +171,27 @@ func (cfg STT) Validate() error {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
// Address is the host:port to listen on.
|
// Address is the host:port to listen on.
|
||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
|
|
||||||
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
|
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
|
||||||
// Defaults to "30s".
|
|
||||||
ShutdownTimeout string `yaml:"shutdown_timeout"`
|
ShutdownTimeout string `yaml:"shutdown_timeout"`
|
||||||
// shutdownTimeout is the parsed duration, set during Load.
|
// shutdownTimeout is the parsed duration, set during Load.
|
||||||
shutdownTimeout time.Duration `yaml:"-"`
|
shutdownTimeout time.Duration `yaml:"-"`
|
||||||
|
// SystemMessage is the default system prompt prepended to all conversations.
|
||||||
// LLM configures the language model client.
|
SystemMessage string `yaml:"system_message"`
|
||||||
LLM llm.Config `yaml:"llm"`
|
// DefaultModel specifies the default provider and model.
|
||||||
|
DefaultModel ModelConfig `yaml:"default_model"`
|
||||||
|
// Providers defines the available LLM providers.
|
||||||
|
Providers []ProviderConfig `yaml:"providers"`
|
||||||
// STT configures the speech-to-text server URL passed to the frontend.
|
// STT configures the speech-to-text server URL passed to the frontend.
|
||||||
STT STT `yaml:"stt"`
|
STT STT `yaml:"stt"`
|
||||||
// Jobs defines scheduled autonomous tasks.
|
// Jobs defines scheduled autonomous tasks.
|
||||||
Jobs []job.Config `yaml:"jobs"`
|
Jobs []JobConfig `yaml:"jobs"`
|
||||||
// Tools defines external commands available to the LLM.
|
// Tools defines external commands available to the LLM.
|
||||||
Tools []tool.Tool `yaml:"tools"`
|
Tools []ToolConfig `yaml:"tools"`
|
||||||
// TTS configures the text-to-speech client.
|
// TTS configures the text-to-speech client.
|
||||||
TTS TTS `yaml:"tts"`
|
TTS TTS `yaml:"tts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults sets default values for optional configuration fields.
|
// ApplyDefaults sets default values for optional configuration fields.
|
||||||
// Called automatically by Load before validation.
|
|
||||||
func (cfg *Config) ApplyDefaults() {
|
func (cfg *Config) ApplyDefaults() {
|
||||||
if cfg.ShutdownTimeout == "" {
|
if cfg.ShutdownTimeout == "" {
|
||||||
cfg.ShutdownTimeout = "30s"
|
cfg.ShutdownTimeout = "30s"
|
||||||
@@ -97,14 +207,23 @@ func (cfg *Config) GetShutdownTimeout() time.Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks that all required fields are present and valid.
|
// Validate checks that all required fields are present and valid.
|
||||||
// Delegates to each subsection's Validate method.
|
|
||||||
func (cfg *Config) Validate() error {
|
func (cfg *Config) Validate() error {
|
||||||
if cfg.Address == "" {
|
if cfg.Address == "" {
|
||||||
return fmt.Errorf("address required")
|
return fmt.Errorf("address required")
|
||||||
}
|
}
|
||||||
|
if cfg.DefaultModel.Provider == "" {
|
||||||
if err := cfg.LLM.Validate(); err != nil {
|
return fmt.Errorf("default_model.provider required")
|
||||||
return fmt.Errorf("invalid llm config: %w", err)
|
}
|
||||||
|
if cfg.DefaultModel.Model == "" {
|
||||||
|
return fmt.Errorf("default_model.model required")
|
||||||
|
}
|
||||||
|
if len(cfg.Providers) == 0 {
|
||||||
|
return fmt.Errorf("at least one provider required")
|
||||||
|
}
|
||||||
|
for i, p := range cfg.Providers {
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("providers[%d] invalid: %w", i, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := cfg.STT.Validate(); err != nil {
|
if err := cfg.STT.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid stt config: %w", err)
|
return fmt.Errorf("invalid stt config: %w", err)
|
||||||
@@ -113,28 +232,23 @@ func (cfg *Config) Validate() error {
|
|||||||
return fmt.Errorf("invalid tts config: %w", err)
|
return fmt.Errorf("invalid tts config: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := time.ParseDuration(cfg.ShutdownTimeout); err != nil {
|
if _, err := time.ParseDuration(cfg.ShutdownTimeout); err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf("invalid shutdown_timeout %q: %w", cfg.ShutdownTimeout, err)
|
||||||
"invalid shutdown_timeout %q: %w",
|
|
||||||
cfg.ShutdownTimeout, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
for i, jc := range cfg.Jobs {
|
for i, jc := range cfg.Jobs {
|
||||||
if err := jc.Validate(); err != nil {
|
if err := jc.Validate(); err != nil {
|
||||||
return fmt.Errorf("jobs[%d] invalid: %w", i, err)
|
return fmt.Errorf("jobs[%d] invalid: %w", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, t := range cfg.Tools {
|
for i, tc := range cfg.Tools {
|
||||||
if err := t.Validate(); err != nil {
|
if err := tc.Validate(); err != nil {
|
||||||
return fmt.Errorf("tools[%d] invalid: %w", i, err)
|
return fmt.Errorf("tools[%d] invalid: %w", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads a YAML configuration file, expands environment variables,
|
// Load reads a YAML configuration file, expands environment variables,
|
||||||
// applies defaults, and validates the result. Returns an error if the
|
// applies defaults, and validates the result.
|
||||||
// file cannot be read, parsed, or contains invalid configuration.
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,23 +256,18 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand environment variables.
|
// Expand environment variables.
|
||||||
// Unset variables are replaced with empty strings.
|
|
||||||
re := regexp.MustCompile(`\$\{([^}]+)\}`)
|
re := regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||||
expanded := re.ReplaceAllStringFunc(
|
expanded := re.ReplaceAllStringFunc(string(data), func(match string) string {
|
||||||
string(data),
|
v := match[2 : len(match)-1]
|
||||||
func(match string) string {
|
return os.Getenv(v)
|
||||||
// Extract variable name from ${VAR}.
|
})
|
||||||
v := match[2 : len(match)-1]
|
|
||||||
return os.Getenv(v)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
|
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parse config: %w", err)
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
}
|
}
|
||||||
cfg.ApplyDefaults()
|
|
||||||
|
|
||||||
|
cfg.ApplyDefaults()
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, fmt.Errorf("invalid config: %v", err)
|
return nil, fmt.Errorf("invalid config: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validConfig returns a minimal valid YAML configuration.
|
// validConfig returns a minimal valid YAML configuration using the
|
||||||
|
// multi-provider format.
|
||||||
func validConfig() string {
|
func validConfig() string {
|
||||||
return `
|
return `
|
||||||
address: ":9090"
|
address: ":9090"
|
||||||
shutdown_timeout: "60s"
|
shutdown_timeout: "60s"
|
||||||
|
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -71,9 +76,13 @@ func TestLoad(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "defaults applied",
|
name: "defaults applied",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -106,10 +115,14 @@ tts:
|
|||||||
{
|
{
|
||||||
name: "env expansion",
|
name: "env expansion",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
key: ${TEST_LLM_KEY}
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
key: ${TEST_LLM_KEY}
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -122,10 +135,13 @@ tts:
|
|||||||
t.Setenv("TEST_LLM_KEY", "secret-api-key")
|
t.Setenv("TEST_LLM_KEY", "secret-api-key")
|
||||||
},
|
},
|
||||||
check: func(t *testing.T, cfg *Config) {
|
check: func(t *testing.T, cfg *Config) {
|
||||||
if cfg.LLM.Key != "secret-api-key" {
|
if len(cfg.Providers) != 1 {
|
||||||
|
t.Fatalf("len(Providers) = %d, want 1", len(cfg.Providers))
|
||||||
|
}
|
||||||
|
if cfg.Providers[0].Key != "secret-api-key" {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"LLM.Key = %q, want %q",
|
"Providers[0].Key = %q, want %q",
|
||||||
cfg.LLM.Key, "secret-api-key",
|
cfg.Providers[0].Key, "secret-api-key",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -133,10 +149,14 @@ tts:
|
|||||||
{
|
{
|
||||||
name: "env expansion unset var becomes empty",
|
name: "env expansion unset var becomes empty",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
key: ${TEST_UNSET_VAR}
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
key: ${TEST_UNSET_VAR}
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -146,17 +166,20 @@ tts:
|
|||||||
voice: af_heart
|
voice: af_heart
|
||||||
`,
|
`,
|
||||||
check: func(t *testing.T, cfg *Config) {
|
check: func(t *testing.T, cfg *Config) {
|
||||||
if cfg.LLM.Key != "" {
|
if len(cfg.Providers) != 1 {
|
||||||
|
t.Fatalf("len(Providers) = %d, want 1", len(cfg.Providers))
|
||||||
|
}
|
||||||
|
if cfg.Providers[0].Key != "" {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"LLM.Key = %q, want empty",
|
"Providers[0].Key = %q, want empty",
|
||||||
cfg.LLM.Key,
|
cfg.Providers[0].Key,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid yaml",
|
name: "invalid yaml",
|
||||||
config: `llm: [invalid yaml`,
|
config: `providers: [invalid yaml`,
|
||||||
shouldErr: true,
|
shouldErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -164,9 +187,13 @@ tts:
|
|||||||
config: `
|
config: `
|
||||||
shutdown_timeout: "not-a-duration"
|
shutdown_timeout: "not-a-duration"
|
||||||
|
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -178,10 +205,14 @@ tts:
|
|||||||
shouldErr: true,
|
shouldErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing llm model",
|
name: "missing default_model.provider",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
url: http://localhost:8080
|
model: test-model
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -193,11 +224,34 @@ tts:
|
|||||||
shouldErr: true,
|
shouldErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing llm url",
|
name: "missing default_model.model",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
|
stt:
|
||||||
|
url: http://localhost:8178
|
||||||
|
|
||||||
|
tts:
|
||||||
|
url: http://localhost:8880
|
||||||
|
voice: af_heart
|
||||||
|
`,
|
||||||
|
shouldErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing provider url",
|
||||||
|
config: `
|
||||||
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
|
|
||||||
@@ -210,9 +264,13 @@ tts:
|
|||||||
{
|
{
|
||||||
name: "missing stt url",
|
name: "missing stt url",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: ""
|
url: ""
|
||||||
@@ -226,9 +284,13 @@ tts:
|
|||||||
{
|
{
|
||||||
name: "missing tts url",
|
name: "missing tts url",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -241,9 +303,13 @@ tts:
|
|||||||
{
|
{
|
||||||
name: "missing tts voice",
|
name: "missing tts voice",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -256,9 +322,13 @@ tts:
|
|||||||
{
|
{
|
||||||
name: "config with tools",
|
name: "config with tools",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -292,9 +362,13 @@ tools:
|
|||||||
{
|
{
|
||||||
name: "invalid tool",
|
name: "invalid tool",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -388,9 +462,13 @@ func TestLoad_Jobs(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid job config",
|
name: "valid job config",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -402,7 +480,6 @@ tts:
|
|||||||
jobs:
|
jobs:
|
||||||
- name: daily_summary
|
- name: daily_summary
|
||||||
schedule: "0 9 * * *"
|
schedule: "0 9 * * *"
|
||||||
enabled: true
|
|
||||||
system_message: "You are a research assistant."
|
system_message: "You are a research assistant."
|
||||||
task: "Summarize today's news about AI."
|
task: "Summarize today's news about AI."
|
||||||
timeout: "10m"
|
timeout: "10m"
|
||||||
@@ -432,17 +509,18 @@ jobs:
|
|||||||
if j.MaxIterations != 20 {
|
if j.MaxIterations != 20 {
|
||||||
t.Errorf("MaxIterations = %d, want 20", j.MaxIterations)
|
t.Errorf("MaxIterations = %d, want 20", j.MaxIterations)
|
||||||
}
|
}
|
||||||
if !j.IsEnabled() {
|
|
||||||
t.Error("IsEnabled() = false, want true")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "job with minimal config (defaults)",
|
name: "job with minimal config (defaults)",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -466,41 +544,16 @@ jobs:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "disabled job",
|
|
||||||
config: `
|
|
||||||
llm:
|
|
||||||
model: test-model
|
|
||||||
url: http://localhost:8080
|
|
||||||
|
|
||||||
stt:
|
|
||||||
url: http://localhost:8178
|
|
||||||
|
|
||||||
tts:
|
|
||||||
url: http://localhost:8880
|
|
||||||
voice: af_heart
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
- name: disabled_job
|
|
||||||
schedule: "0 0 * * *"
|
|
||||||
enabled: false
|
|
||||||
task: "This should not run."
|
|
||||||
`,
|
|
||||||
check: func(t *testing.T, cfg *Config) {
|
|
||||||
if len(cfg.Jobs) != 1 {
|
|
||||||
t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs))
|
|
||||||
}
|
|
||||||
if cfg.Jobs[0].IsEnabled() {
|
|
||||||
t.Error("IsEnabled() = true, want false")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "job missing name",
|
name: "job missing name",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
@@ -518,9 +571,13 @@ jobs:
|
|||||||
{
|
{
|
||||||
name: "job missing task",
|
name: "job missing task",
|
||||||
config: `
|
config: `
|
||||||
llm:
|
default_model:
|
||||||
|
provider: testprovider
|
||||||
model: test-model
|
model: test-model
|
||||||
url: http://localhost:8080
|
|
||||||
|
providers:
|
||||||
|
- name: testprovider
|
||||||
|
url: http://localhost:8080
|
||||||
|
|
||||||
stt:
|
stt:
|
||||||
url: http://localhost:8178
|
url: http://localhost:8178
|
||||||
|
|||||||
@@ -15,99 +15,41 @@ import (
|
|||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
"github.com/sashabaranov/go-openai"
|
"github.com/sashabaranov/go-openai"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||||
"code.chimeric.al/chimerical/odidere/internal/llm"
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the user-facing configuration for a job.
|
|
||||||
// It is unmarshaled from YAML and passed to New, which validates and
|
|
||||||
// parses it into an immutable Job.
|
|
||||||
type Config struct {
|
|
||||||
// Enabled controls whether the job is active. Defaults to true.
|
|
||||||
Enabled *bool `yaml:"enabled,omitempty"`
|
|
||||||
// MaxIterations caps the number of agent loop iterations.
|
|
||||||
// A value of 0 means unlimited (no cap).
|
|
||||||
MaxIterations int `yaml:"max_iterations,omitempty"`
|
|
||||||
// Name uniquely identifies the job within the config.
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
// Schedule is a cron expression (e.g., "0 9 * * *").
|
|
||||||
Schedule string `yaml:"schedule"`
|
|
||||||
// SystemMessage overrides the global system message for this job.
|
|
||||||
SystemMessage string `yaml:"system_message,omitempty"`
|
|
||||||
// Task is the user-level instruction to give the LLM.
|
|
||||||
Task string `yaml:"task"`
|
|
||||||
// Timeout limits the job execution time (e.g., "10m").
|
|
||||||
// Defaults to the global LLM timeout if empty.
|
|
||||||
Timeout string `yaml:"timeout,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEnabled returns whether the job should be scheduled.
|
|
||||||
// If Enabled is nil, the job is considered enabled by default.
|
|
||||||
func (cfg Config) IsEnabled() bool {
|
|
||||||
if cfg.Enabled == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return *cfg.Enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks that required fields are present and constraints hold.
|
|
||||||
// It does not parse values — that is the responsibility of New.
|
|
||||||
func (cfg Config) Validate() error {
|
|
||||||
if cfg.Name == "" {
|
|
||||||
return fmt.Errorf("missing name")
|
|
||||||
}
|
|
||||||
if cfg.Task == "" {
|
|
||||||
return fmt.Errorf("missing task")
|
|
||||||
}
|
|
||||||
if cfg.Schedule == "" {
|
|
||||||
return fmt.Errorf("missing schedule")
|
|
||||||
}
|
|
||||||
if cfg.MaxIterations < 0 {
|
|
||||||
return fmt.Errorf("max_iterations must be non-negative")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job defines a scheduled autonomous task.
|
// Job defines a scheduled autonomous task.
|
||||||
//
|
//
|
||||||
// Job is immutable after construction via New. All fields are private and
|
// Job is immutable after construction via New. All fields are private and
|
||||||
// accessed through getter methods. This ensures that a Job's configuration
|
// accessed through getter methods. This ensures that a Job's configuration
|
||||||
// cannot change once it is handed to the service.
|
// cannot change once it is handed to the service.
|
||||||
type Job struct {
|
type Job struct {
|
||||||
// cron is the cron.Schedule derived from schedule during
|
// cron is the cron.Schedule derived from
|
||||||
// construction. This is what the robfig/cron package uses to determine
|
// schedule during construction.
|
||||||
// when the job should fire.
|
|
||||||
cron cron.Schedule
|
cron cron.Schedule
|
||||||
|
|
||||||
// enabled indicates whether the job should be registered with the cron
|
|
||||||
// scheduler.
|
|
||||||
enabled bool
|
|
||||||
|
|
||||||
// log is the structured logger used for job execution.
|
// log is the structured logger used for job execution.
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
// llm is the LLM client used to execute the job.
|
// llm is the LLM client used to execute the job.
|
||||||
llm *llm.Client
|
llm *llm.Client
|
||||||
|
// model is the model ID to pass to the LLM client (without provider
|
||||||
|
// prefix, which is resolved at construction time).
|
||||||
|
model string
|
||||||
// maxIterations caps the agent loop iterations for this job. Zero means
|
// maxIterations caps the agent loop iterations for this job. Zero means
|
||||||
// unlimited.
|
// unlimited.
|
||||||
maxIterations int
|
maxIterations int
|
||||||
|
|
||||||
// name uniquely identifies the job within the config. It is used for
|
// name uniquely identifies the job within the config. It is used for
|
||||||
// logging, scheduling, and lookup.
|
// logging, scheduling, and lookup.
|
||||||
name string
|
name string
|
||||||
|
|
||||||
// schedule is the original cron expression string from the config
|
// schedule is the original cron expression string from the config
|
||||||
// (e.g. "0 9 * * *"). Stored for logging and display purposes.
|
// (e.g. "0 9 * * *"). Stored for logging and display purposes.
|
||||||
schedule string
|
schedule string
|
||||||
|
|
||||||
// systemMessage is the per-job system prompt that overrides the global
|
// systemMessage is the per-job system prompt that overrides the global
|
||||||
// LLM system message. Empty string means use the default.
|
// LLM system message. Empty string means use the default.
|
||||||
systemMessage string
|
systemMessage string
|
||||||
|
|
||||||
// task is the user-level instruction passed to the LLM when the job
|
// task is the user-level instruction passed to the LLM when the job
|
||||||
// fires. This is the core of what the job does.
|
// fires. This is the core of what the job does.
|
||||||
task string
|
task string
|
||||||
|
|
||||||
// timeout is the parsed execution time limit. Zero means no per-job
|
// timeout is the parsed execution time limit. Zero means no per-job
|
||||||
// timeout (falls back to the global LLM timeout).
|
// timeout (falls back to the global LLM timeout).
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
@@ -115,12 +57,10 @@ type Job struct {
|
|||||||
|
|
||||||
// New creates a new Job from a Config.
|
// New creates a new Job from a Config.
|
||||||
// log is the structured logger used for job execution.
|
// log is the structured logger used for job execution.
|
||||||
// llm is the LLM client used to execute the job.
|
// llmc is the LLM client used to execute the job.
|
||||||
func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) {
|
// model is the model ID (without provider prefix) to pass to the client.
|
||||||
if err := cfg.Validate(); err != nil {
|
func New(cfg config.JobConfig, log *slog.Logger, llmc *llm.Client, model string) (*Job, error) {
|
||||||
return nil, fmt.Errorf("invalid config: %w", err)
|
// Validation is handled by config.Config.Validate().
|
||||||
}
|
|
||||||
|
|
||||||
sched, err := cron.ParseStandard(cfg.Schedule)
|
sched, err := cron.ParseStandard(cfg.Schedule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -138,16 +78,11 @@ func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enabled := true
|
|
||||||
if cfg.Enabled != nil {
|
|
||||||
enabled = *cfg.Enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Job{
|
return &Job{
|
||||||
cron: sched,
|
cron: sched,
|
||||||
enabled: enabled,
|
|
||||||
log: log.With(slog.String("job", cfg.Name)),
|
log: log.With(slog.String("job", cfg.Name)),
|
||||||
llm: llmClient,
|
llm: llmc,
|
||||||
|
model: model,
|
||||||
maxIterations: cfg.MaxIterations,
|
maxIterations: cfg.MaxIterations,
|
||||||
name: cfg.Name,
|
name: cfg.Name,
|
||||||
schedule: cfg.Schedule,
|
schedule: cfg.Schedule,
|
||||||
@@ -158,45 +93,28 @@ func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cron returns the parsed cron schedule.
|
// Cron returns the parsed cron schedule.
|
||||||
func (j *Job) Cron() cron.Schedule {
|
func (j *Job) Cron() cron.Schedule { return j.cron }
|
||||||
return j.cron
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEnabled returns whether the job should be scheduled.
|
// MaxIterations returns the configured max iterations.
|
||||||
func (j *Job) IsEnabled() bool {
|
func (j *Job) MaxIterations() int { return j.maxIterations }
|
||||||
return j.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxIterations returns the configured max iterations. A value of 0
|
// Model returns the model ID used by the job.
|
||||||
// means unlimited (no cap on agent loop iterations).
|
func (j *Job) Model() string { return j.model }
|
||||||
func (j *Job) MaxIterations() int {
|
|
||||||
return j.maxIterations
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the job name.
|
// Name returns the job name.
|
||||||
func (j *Job) Name() string {
|
func (j *Job) Name() string { return j.name }
|
||||||
return j.name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule returns the cron schedule string.
|
// Schedule returns the job schedule string.
|
||||||
func (j *Job) Schedule() string {
|
func (j *Job) Schedule() string { return j.schedule }
|
||||||
return j.schedule
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemMessage returns the job's system message.
|
// SystemMessage returns the job's system message.
|
||||||
func (j *Job) SystemMessage() string {
|
func (j *Job) SystemMessage() string { return j.systemMessage }
|
||||||
return j.systemMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task returns the job's task description.
|
// Task returns the job's task description.
|
||||||
func (j *Job) Task() string {
|
func (j *Job) Task() string { return j.task }
|
||||||
return j.task
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout returns the job's timeout duration.
|
// Timeout returns the job's timeout duration.
|
||||||
func (j *Job) Timeout() time.Duration {
|
func (j *Job) Timeout() time.Duration { return j.timeout }
|
||||||
return j.timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result holds the outcome of a job execution.
|
// Result holds the outcome of a job execution.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -212,7 +130,7 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run executes the job: builds messages from the job config, calls the LLM,
|
// Run executes the job: builds messages from the job config, calls the LLM,
|
||||||
// and logs progress. The agent uses tools for any additional output.
|
// and logs the result. The agent uses tools for any additional output.
|
||||||
// Uses j.log and j.llm internally.
|
// Uses j.log and j.llm internally.
|
||||||
func (j *Job) Run(ctx context.Context) Result {
|
func (j *Job) Run(ctx context.Context) Result {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -237,13 +155,16 @@ func (j *Job) Run(ctx context.Context) Result {
|
|||||||
msgs, err := j.llm.Query(
|
msgs, err := j.llm.Query(
|
||||||
ctx,
|
ctx,
|
||||||
messages,
|
messages,
|
||||||
"",
|
j.model,
|
||||||
j.SystemMessage(),
|
j.SystemMessage(),
|
||||||
j.MaxIterations(),
|
j.MaxIterations(),
|
||||||
)
|
)
|
||||||
|
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("job failed",
|
log.ErrorContext(
|
||||||
|
ctx,
|
||||||
|
"job failed",
|
||||||
slog.Any("error", err),
|
slog.Any("error", err),
|
||||||
slog.Duration("duration", duration),
|
slog.Duration("duration", duration),
|
||||||
)
|
)
|
||||||
@@ -254,7 +175,9 @@ func (j *Job) Run(ctx context.Context) Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(msgs) == 0 {
|
if len(msgs) == 0 {
|
||||||
log.Error("job returned no messages",
|
log.ErrorContext(
|
||||||
|
ctx,
|
||||||
|
"job returned no messages",
|
||||||
slog.Duration("duration", duration),
|
slog.Duration("duration", duration),
|
||||||
)
|
)
|
||||||
return Result{
|
return Result{
|
||||||
@@ -265,11 +188,13 @@ func (j *Job) Run(ctx context.Context) Result {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Info("job completed",
|
|
||||||
|
log.InfoContext(
|
||||||
|
ctx,
|
||||||
|
"job completed",
|
||||||
slog.Duration("duration", duration),
|
slog.Duration("duration", duration),
|
||||||
slog.Int("messages", len(msgs)),
|
slog.Int("messages", len(msgs)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Result{
|
return Result{
|
||||||
Name: j.Name(),
|
Name: j.Name(),
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
|
|||||||
@@ -4,22 +4,24 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigValidate(t *testing.T) {
|
func TestConfigValidate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config Config
|
config config.JobConfig
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty config",
|
name: "empty config",
|
||||||
config: Config{},
|
config: config.JobConfig{},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing name",
|
name: "missing name",
|
||||||
config: Config{
|
config: config.JobConfig{
|
||||||
Schedule: "0 9 * * *",
|
Schedule: "0 9 * * *",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
},
|
},
|
||||||
@@ -27,7 +29,7 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing task",
|
name: "missing task",
|
||||||
config: Config{
|
config: config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Schedule: "0 9 * * *",
|
Schedule: "0 9 * * *",
|
||||||
},
|
},
|
||||||
@@ -35,7 +37,7 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing schedule",
|
name: "missing schedule",
|
||||||
config: Config{
|
config: config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
},
|
},
|
||||||
@@ -43,7 +45,7 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "negative max iterations",
|
name: "negative max iterations",
|
||||||
config: Config{
|
config: config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
Schedule: "0 9 * * *",
|
Schedule: "0 9 * * *",
|
||||||
@@ -53,7 +55,7 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "minimal valid config",
|
name: "minimal valid config",
|
||||||
config: Config{
|
config: config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
Schedule: "0 9 * * *",
|
Schedule: "0 9 * * *",
|
||||||
@@ -62,7 +64,7 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "full valid config",
|
name: "full valid config",
|
||||||
config: Config{
|
config: config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
Schedule: "0 9 * * *",
|
Schedule: "0 9 * * *",
|
||||||
@@ -84,31 +86,9 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigIsEnabled(t *testing.T) {
|
|
||||||
// nil enabled → defaults to true
|
|
||||||
cfg := Config{Name: "test"}
|
|
||||||
if !cfg.IsEnabled() {
|
|
||||||
t.Error("expected nil enabled to be true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// explicit true
|
|
||||||
enabled := true
|
|
||||||
cfg.Enabled = &enabled
|
|
||||||
if !cfg.IsEnabled() {
|
|
||||||
t.Error("expected explicit true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// explicit false
|
|
||||||
disabled := false
|
|
||||||
cfg.Enabled = &disabled
|
|
||||||
if cfg.IsEnabled() {
|
|
||||||
t.Error("expected explicit false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigMaxIterations(t *testing.T) {
|
func TestConfigMaxIterations(t *testing.T) {
|
||||||
// Zero means unlimited
|
// Zero means unlimited
|
||||||
cfg := Config{Name: "test"}
|
cfg := config.JobConfig{Name: "test"}
|
||||||
if got := cfg.MaxIterations; got != 0 {
|
if got := cfg.MaxIterations; got != 0 {
|
||||||
t.Errorf("default max iterations = %d, want 0", got)
|
t.Errorf("default max iterations = %d, want 0", got)
|
||||||
}
|
}
|
||||||
@@ -126,7 +106,7 @@ func TestConfigMaxIterations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigTimeout(t *testing.T) {
|
func TestConfigTimeout(t *testing.T) {
|
||||||
cfg := Config{Name: "test"}
|
cfg := config.JobConfig{Name: "test"}
|
||||||
if cfg.Timeout != "" {
|
if cfg.Timeout != "" {
|
||||||
t.Errorf("default timeout = %q, want empty", cfg.Timeout)
|
t.Errorf("default timeout = %q, want empty", cfg.Timeout)
|
||||||
}
|
}
|
||||||
@@ -142,7 +122,7 @@ func TestConfigTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
cfg := Config{
|
cfg := config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
Schedule: "0 9 * * *",
|
Schedule: "0 9 * * *",
|
||||||
@@ -151,7 +131,7 @@ func TestNew(t *testing.T) {
|
|||||||
SystemMessage: "You are an assistant.",
|
SystemMessage: "You are an assistant.",
|
||||||
}
|
}
|
||||||
|
|
||||||
j, err := New(cfg, slog.Default(), nil)
|
j, err := New(cfg, slog.Default(), nil, "test-model")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -162,9 +142,6 @@ func TestNew(t *testing.T) {
|
|||||||
if j.Schedule() != "0 9 * * *" {
|
if j.Schedule() != "0 9 * * *" {
|
||||||
t.Errorf("Schedule() = %q, want %q", j.Schedule(), "0 9 * * *")
|
t.Errorf("Schedule() = %q, want %q", j.Schedule(), "0 9 * * *")
|
||||||
}
|
}
|
||||||
if !j.IsEnabled() {
|
|
||||||
t.Error("IsEnabled() = false, want true")
|
|
||||||
}
|
|
||||||
if j.SystemMessage() != "You are an assistant." {
|
if j.SystemMessage() != "You are an assistant." {
|
||||||
t.Errorf("SystemMessage() = %q, want %q", j.SystemMessage(), "You are an assistant.")
|
t.Errorf("SystemMessage() = %q, want %q", j.SystemMessage(), "You are an assistant.")
|
||||||
}
|
}
|
||||||
@@ -177,52 +154,36 @@ func TestNew(t *testing.T) {
|
|||||||
if j.MaxIterations() != 20 {
|
if j.MaxIterations() != 20 {
|
||||||
t.Errorf("MaxIterations() = %d, want 20", j.MaxIterations())
|
t.Errorf("MaxIterations() = %d, want 20", j.MaxIterations())
|
||||||
}
|
}
|
||||||
|
if j.Model() != "test-model" {
|
||||||
|
t.Errorf("Model() = %q, want %q", j.Model(), "test-model")
|
||||||
|
}
|
||||||
if j.Cron() == nil {
|
if j.Cron() == nil {
|
||||||
t.Error("Cron() = nil, want non-nil")
|
t.Error("Cron() = nil, want non-nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_Disabled(t *testing.T) {
|
|
||||||
disabled := false
|
|
||||||
cfg := Config{
|
|
||||||
Name: "test",
|
|
||||||
Task: "do something",
|
|
||||||
Schedule: "0 9 * * *",
|
|
||||||
Enabled: &disabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
j, err := New(cfg, slog.Default(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if j.IsEnabled() {
|
|
||||||
t.Error("IsEnabled() = true, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNew_InvalidSchedule(t *testing.T) {
|
func TestNew_InvalidSchedule(t *testing.T) {
|
||||||
cfg := Config{
|
cfg := config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
Schedule: "not-a-cron",
|
Schedule: "not-a-cron",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := New(cfg, slog.Default(), nil)
|
_, err := New(cfg, slog.Default(), nil, "test-model")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("New() expected error for invalid schedule")
|
t.Fatal("New() expected error for invalid schedule")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_InvalidTimeout(t *testing.T) {
|
func TestNew_InvalidTimeout(t *testing.T) {
|
||||||
cfg := Config{
|
cfg := config.JobConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Task: "do something",
|
Task: "do something",
|
||||||
Schedule: "0 9 * * *",
|
Schedule: "0 9 * * *",
|
||||||
Timeout: "not-a-duration",
|
Timeout: "not-a-duration",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := New(cfg, slog.Default(), nil)
|
_, err := New(cfg, slog.Default(), nil, "test-model")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("New() expected error for invalid timeout")
|
t.Fatal("New() expected error for invalid timeout")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
// Key is the API key for authentication.
|
// Key is the API key for authentication.
|
||||||
Key string `yaml:"key"`
|
Key string `yaml:"key"`
|
||||||
// Model is the model identifier.
|
|
||||||
Model string `yaml:"model"`
|
|
||||||
// SystemMessage is prepended to all conversations.
|
// SystemMessage is prepended to all conversations.
|
||||||
SystemMessage string `yaml:"system_message"`
|
SystemMessage string `yaml:"system_message"`
|
||||||
// Timeout is the maximum duration for a query (e.g., "5m").
|
// Timeout is the maximum duration for a query (e.g., "5m").
|
||||||
@@ -33,9 +31,6 @@ type Config struct {
|
|||||||
|
|
||||||
// Validate checks that required configuration values are present and valid.
|
// Validate checks that required configuration values are present and valid.
|
||||||
func (cfg Config) Validate() error {
|
func (cfg Config) Validate() error {
|
||||||
if cfg.Model == "" {
|
|
||||||
return fmt.Errorf("missing model")
|
|
||||||
}
|
|
||||||
if cfg.Timeout != "" {
|
if cfg.Timeout != "" {
|
||||||
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||||
return fmt.Errorf("invalid timeout: %w", err)
|
return fmt.Errorf("invalid timeout: %w", err)
|
||||||
@@ -51,7 +46,6 @@ func (cfg Config) Validate() error {
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
client *openai.Client
|
client *openai.Client
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
model string
|
|
||||||
registry *tool.Registry
|
registry *tool.Registry
|
||||||
systemMessage string
|
systemMessage string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
@@ -71,7 +65,6 @@ func NewClient(
|
|||||||
|
|
||||||
llm := &Client{
|
llm := &Client{
|
||||||
log: log,
|
log: log,
|
||||||
model: cfg.Model,
|
|
||||||
systemMessage: cfg.SystemMessage,
|
systemMessage: cfg.SystemMessage,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
}
|
}
|
||||||
@@ -115,17 +108,11 @@ func (c *Client) ListModels(ctx context.Context) ([]openai.Model, error) {
|
|||||||
return res.Models, nil
|
return res.Models, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultModel returns the configured default model.
|
|
||||||
func (c *Client) DefaultModel() string {
|
|
||||||
return c.model
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrMaxIterations is returned when the agent loop exceeds the configured
|
// ErrMaxIterations is returned when the agent loop exceeds the configured
|
||||||
// maximum number of iterations.
|
// maximum number of iterations.
|
||||||
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
||||||
|
|
||||||
// Query sends messages to the LLM using the specified model.
|
// Query sends messages to the LLM using the specified model.
|
||||||
// If model is empty, uses the default configured model.
|
|
||||||
// If systemMessage is non-empty, it overrides the configured system message.
|
// If systemMessage is non-empty, it overrides the configured system message.
|
||||||
// maxIterations caps the number of agent loop iterations; a value of 0
|
// maxIterations caps the number of agent loop iterations; a value of 0
|
||||||
// means unlimited (no cap).
|
// means unlimited (no cap).
|
||||||
@@ -141,11 +128,6 @@ func (c *Client) Query(
|
|||||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Fallback to the default model.
|
|
||||||
if model == "" {
|
|
||||||
model = c.model
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use per-request system message if provided, otherwise fall back to config.
|
// Use per-request system message if provided, otherwise fall back to config.
|
||||||
effectiveMessage := c.systemMessage
|
effectiveMessage := c.systemMessage
|
||||||
if systemMessage != "" {
|
if systemMessage != "" {
|
||||||
@@ -273,11 +255,6 @@ func (c *Client) QueryStream(
|
|||||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Fallback to the default model.
|
|
||||||
if model == "" {
|
|
||||||
model = c.model
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use per-request system message if provided, otherwise fall back to config.
|
// Use per-request system message if provided, otherwise fall back to config.
|
||||||
effectiveMessage := c.systemMessage
|
effectiveMessage := c.systemMessage
|
||||||
if systemMessage != "" {
|
if systemMessage != "" {
|
||||||
@@ -412,11 +389,6 @@ func (c *Client) QueryStream(
|
|||||||
result = fmt.Sprintf(
|
result = fmt.Sprintf(
|
||||||
`{"ok": false, "error": %q}`, err,
|
`{"ok": false, "error": %q}`, err,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
c.log.Info(
|
|
||||||
"called tool",
|
|
||||||
slog.String("name", tc.Function.Name),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Content cannot be empty.
|
// Content cannot be empty.
|
||||||
if strings.TrimSpace(result) == "" {
|
if strings.TrimSpace(result) == "" {
|
||||||
|
|||||||
@@ -16,23 +16,13 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing model",
|
name: "missing URL",
|
||||||
cfg: Config{
|
cfg: Config{},
|
||||||
URL: "http://localhost:8080",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing URL",
|
|
||||||
cfg: Config{
|
|
||||||
Model: "test-model",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid timeout",
|
name: "invalid timeout",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Model: "test-model",
|
|
||||||
URL: "http://localhost:8080",
|
URL: "http://localhost:8080",
|
||||||
Timeout: "not-a-duration",
|
Timeout: "not-a-duration",
|
||||||
},
|
},
|
||||||
@@ -41,15 +31,13 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid minimal config",
|
name: "valid minimal config",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Model: "test-model",
|
URL: "http://localhost:8080",
|
||||||
URL: "http://localhost:8080",
|
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid config with timeout",
|
name: "valid config with timeout",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Model: "test-model",
|
|
||||||
URL: "http://localhost:8080",
|
URL: "http://localhost:8080",
|
||||||
Timeout: "15m",
|
Timeout: "15m",
|
||||||
},
|
},
|
||||||
@@ -58,7 +46,6 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid config with complex timeout",
|
name: "valid config with complex timeout",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Model: "test-model",
|
|
||||||
URL: "http://localhost:8080",
|
URL: "http://localhost:8080",
|
||||||
Timeout: "1h30m45s",
|
Timeout: "1h30m45s",
|
||||||
},
|
},
|
||||||
@@ -68,7 +55,6 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
name: "valid full config",
|
name: "valid full config",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Key: "sk-test-key",
|
Key: "sk-test-key",
|
||||||
Model: "test-model",
|
|
||||||
SystemMessage: "You are a helpful assistant.",
|
SystemMessage: "You are a helpful assistant.",
|
||||||
Timeout: "30m",
|
Timeout: "30m",
|
||||||
URL: "http://localhost:8080",
|
URL: "http://localhost:8080",
|
||||||
@@ -104,15 +90,13 @@ func TestNewClient(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid config without timeout",
|
name: "valid config without timeout",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Model: "test-model",
|
URL: "http://localhost:8080",
|
||||||
URL: "http://localhost:8080",
|
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid config with timeout",
|
name: "valid config with timeout",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Model: "test-model",
|
|
||||||
URL: "http://localhost:8080",
|
URL: "http://localhost:8080",
|
||||||
Timeout: "10m",
|
Timeout: "10m",
|
||||||
},
|
},
|
||||||
@@ -122,7 +106,6 @@ func TestNewClient(t *testing.T) {
|
|||||||
name: "valid config with all fields",
|
name: "valid config with all fields",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Key: "test-key",
|
Key: "test-key",
|
||||||
Model: "test-model",
|
|
||||||
SystemMessage: "Test message",
|
SystemMessage: "Test message",
|
||||||
Timeout: "5m",
|
Timeout: "5m",
|
||||||
URL: "http://localhost:8080",
|
URL: "http://localhost:8080",
|
||||||
@@ -147,22 +130,3 @@ func TestNewClient(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientDefaultModel(t *testing.T) {
|
|
||||||
cfg := Config{
|
|
||||||
Model: "my-custom-model",
|
|
||||||
URL: "http://localhost:8080",
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := NewClient(cfg, nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewClient() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := client.DefaultModel(); got != "my-custom-model" {
|
|
||||||
t.Errorf(
|
|
||||||
"DefaultModel() = %q, want %q",
|
|
||||||
got, "my-custom-model",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package service orchestrates the odidere voice assistant server.
|
// Package service orchestrates the odidere voice assistant server.
|
||||||
// It coordinates the HTTP server, LLM client, and handles
|
// It coordinates the HTTP server, LLM clients, and handles
|
||||||
// graceful shutdown.
|
// graceful shutdown.
|
||||||
package service
|
package service
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ const (
|
|||||||
logKey key = "log"
|
logKey key = "log"
|
||||||
idKey key = "id"
|
idKey key = "id"
|
||||||
ipKey key = "ip"
|
ipKey key = "ip"
|
||||||
|
|
||||||
|
modelsTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:static/*
|
//go:embed all:static/*
|
||||||
@@ -45,15 +48,16 @@ var static embed.FS
|
|||||||
// Service is the main application coordinator.
|
// Service is the main application coordinator.
|
||||||
// It owns the HTTP server and all processing clients.
|
// It owns the HTTP server and all processing clients.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
jobs []*job.Job
|
jobs []*job.Job
|
||||||
llm *llm.Client
|
llms map[string]*llm.Client
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
mux *http.ServeMux
|
allowedModels map[string]map[string]struct{}
|
||||||
server *http.Server
|
mux *http.ServeMux
|
||||||
tmpl *template.Template
|
server *http.Server
|
||||||
tools *tool.Registry
|
tmpl *template.Template
|
||||||
|
tools *tool.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Service from the provided configuration.
|
// New creates a Service from the provided configuration.
|
||||||
@@ -72,19 +76,80 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
|||||||
}
|
}
|
||||||
svc.tools = registry
|
svc.tools = registry
|
||||||
|
|
||||||
// Create LLM client.
|
// Create LLM clients for each provider.
|
||||||
llmClient, err := llm.NewClient(cfg.LLM, registry, log)
|
svc.llms = make(map[string]*llm.Client, len(cfg.Providers))
|
||||||
if err != nil {
|
for _, pc := range cfg.Providers {
|
||||||
return nil, fmt.Errorf("create LLM client: %v", err)
|
client, err := llm.NewClient(
|
||||||
|
llm.Config{
|
||||||
|
Key: pc.Key,
|
||||||
|
SystemMessage: cfg.SystemMessage,
|
||||||
|
Timeout: pc.Timeout,
|
||||||
|
URL: pc.URL,
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"create LLM client for provider %q: %w",
|
||||||
|
pc.Name, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
svc.llms[pc.Name] = client
|
||||||
|
log.Info(
|
||||||
|
"LLM provider registered",
|
||||||
|
slog.String("provider", pc.Name),
|
||||||
|
slog.String("url", pc.URL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build model allowed list map: provider name -> set of base model IDs.
|
||||||
|
svc.allowedModels = make(
|
||||||
|
map[string]map[string]struct{}, len(cfg.Providers),
|
||||||
|
)
|
||||||
|
for _, pc := range cfg.Providers {
|
||||||
|
if len(pc.Models) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allowed := make(map[string]struct{}, len(pc.Models))
|
||||||
|
for _, m := range pc.Models {
|
||||||
|
base, _, _ := strings.Cut(m, ":")
|
||||||
|
allowed[base] = struct{}{}
|
||||||
|
}
|
||||||
|
svc.allowedModels[pc.Name] = allowed
|
||||||
}
|
}
|
||||||
svc.llm = llmClient
|
|
||||||
|
|
||||||
// Convert job configs to jobs.
|
// Convert job configs to jobs.
|
||||||
jobs := make([]*job.Job, 0, len(cfg.Jobs))
|
jobs := make([]*job.Job, 0, len(cfg.Jobs))
|
||||||
for _, jc := range cfg.Jobs {
|
for _, jc := range cfg.Jobs {
|
||||||
j, err := job.New(jc, log, svc.llm)
|
// Resolve the provider and model for this job.
|
||||||
|
var provider, modelID string
|
||||||
|
if jc.Model.Provider != "" {
|
||||||
|
if jc.Model.Model == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"job %q: model.provider set but model.model is empty",
|
||||||
|
jc.Name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
provider = jc.Model.Provider
|
||||||
|
modelID = jc.Model.Model
|
||||||
|
} else {
|
||||||
|
provider = svc.cfg.DefaultModel.Provider
|
||||||
|
modelID = svc.cfg.DefaultModel.Model
|
||||||
|
}
|
||||||
|
llmc := svc.llms[provider]
|
||||||
|
if llmc == nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"create job %q: no LLM client for provider %q",
|
||||||
|
jc.Name,
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
j, err := job.New(jc, log, llmc, modelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create job %q: %w", jc.Name, err)
|
return nil, fmt.Errorf(
|
||||||
|
"create job %q: %w", jc.Name, err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
jobs = append(jobs, j)
|
jobs = append(jobs, j)
|
||||||
}
|
}
|
||||||
@@ -132,8 +197,9 @@ func (svc *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
id = uuid.NewString()
|
id = uuid.NewString()
|
||||||
ip = func() string {
|
ip = func() string {
|
||||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||||
if idx := strings.Index(ip, ","); idx != -1 {
|
before, _, found := strings.Cut(ip, ",")
|
||||||
return strings.TrimSpace(ip[:idx])
|
if found {
|
||||||
|
return strings.TrimSpace(before)
|
||||||
}
|
}
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
@@ -196,8 +262,14 @@ func (svc *Service) Run(ctx context.Context) error {
|
|||||||
"starting odidere",
|
"starting odidere",
|
||||||
slog.Group(
|
slog.Group(
|
||||||
"llm",
|
"llm",
|
||||||
slog.String("url", svc.cfg.LLM.URL),
|
slog.String(
|
||||||
slog.String("model", svc.cfg.LLM.Model),
|
"default_provider",
|
||||||
|
svc.cfg.DefaultModel.Provider,
|
||||||
|
),
|
||||||
|
slog.String(
|
||||||
|
"default_model", svc.cfg.DefaultModel.Model,
|
||||||
|
),
|
||||||
|
slog.Int("providers", len(svc.cfg.Providers)),
|
||||||
),
|
),
|
||||||
slog.Group(
|
slog.Group(
|
||||||
"server",
|
"server",
|
||||||
@@ -237,10 +309,6 @@ func (svc *Service) Run(ctx context.Context) error {
|
|||||||
// Register jobs with cron scheduler.
|
// Register jobs with cron scheduler.
|
||||||
svc.cron = cron.New()
|
svc.cron = cron.New()
|
||||||
for _, j := range svc.jobs {
|
for _, j := range svc.jobs {
|
||||||
if !j.IsEnabled() {
|
|
||||||
svc.log.Info("job disabled", slog.String("name", j.Name()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry, err := svc.cron.AddFunc(j.Schedule(), func() {
|
entry, err := svc.cron.AddFunc(j.Schedule(), func() {
|
||||||
result := j.Run(ctx)
|
result := j.Run(ctx)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -374,6 +442,8 @@ func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
|
|||||||
type Request struct {
|
type Request struct {
|
||||||
// Messages is the conversation history.
|
// Messages is the conversation history.
|
||||||
Messages []openai.ChatCompletionMessage `json:"messages"`
|
Messages []openai.ChatCompletionMessage `json:"messages"`
|
||||||
|
// Provider is the LLM provider name. If empty, the default provider is used.
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
// Model is the LLM model ID. If empty, the default model is used.
|
// Model is the LLM model ID. If empty, the default model is used.
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
// SystemMessage overrides the configured system message for this request.
|
// SystemMessage overrides the configured system message for this request.
|
||||||
@@ -387,6 +457,8 @@ type Response struct {
|
|||||||
// Messages is the full list of messages generated during the query,
|
// Messages is the full list of messages generated during the query,
|
||||||
// including tool calls and tool results.
|
// including tool calls and tool results.
|
||||||
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
|
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
|
||||||
|
// Provider is the LLM provider used for the response.
|
||||||
|
Provider string `json:"used_provider,omitempty"`
|
||||||
// Model is the LLM model used for the response.
|
// Model is the LLM model used for the response.
|
||||||
Model string `json:"used_model,omitempty"`
|
Model string `json:"used_model,omitempty"`
|
||||||
// Voice is the voice used for TTS synthesis.
|
// Voice is the voice used for TTS synthesis.
|
||||||
@@ -422,12 +494,53 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
slog.Any("data", req.Messages),
|
slog.Any("data", req.Messages),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get LLM response.
|
// Resolve provider and model.
|
||||||
var model = req.Model
|
provider := req.Provider
|
||||||
if model == "" {
|
if provider == "" {
|
||||||
model = svc.llm.DefaultModel()
|
provider = svc.cfg.DefaultModel.Provider
|
||||||
}
|
}
|
||||||
msgs, err := svc.llm.Query(ctx, req.Messages, model, req.SystemMessage, 0)
|
llmc := svc.llms[provider]
|
||||||
|
if llmc == nil {
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx,
|
||||||
|
"unknown provider",
|
||||||
|
slog.String("provider", provider),
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf("unknown provider: %q", provider),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model := req.Model
|
||||||
|
if model == "" {
|
||||||
|
model = svc.cfg.DefaultModel.Model
|
||||||
|
}
|
||||||
|
// Validate model against provider allowed list, if configured.
|
||||||
|
if allowed, ok := svc.allowedModels[provider]; ok {
|
||||||
|
m, _, _ := strings.Cut(model, ":")
|
||||||
|
if _, ok := allowed[m]; !ok {
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx,
|
||||||
|
"model not in allowed list",
|
||||||
|
slog.String("provider", provider),
|
||||||
|
slog.String("model", model),
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"model %q not allowed for provider %q",
|
||||||
|
model, provider,
|
||||||
|
),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := llmc.Query(ctx, req.Messages, model, req.SystemMessage, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.ErrorContext(
|
log.ErrorContext(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -450,12 +563,14 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx,
|
ctx,
|
||||||
"LLM response",
|
"LLM response",
|
||||||
slog.String("text", final.Content),
|
slog.String("text", final.Content),
|
||||||
|
slog.String("provider", provider),
|
||||||
slog.String("model", model),
|
slog.String("model", model),
|
||||||
)
|
)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(Response{
|
if err := json.NewEncoder(w).Encode(Response{
|
||||||
Messages: msgs,
|
Messages: msgs,
|
||||||
|
Provider: provider,
|
||||||
Model: model,
|
Model: model,
|
||||||
Voice: req.Voice,
|
Voice: req.Voice,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -473,6 +588,8 @@ type StreamMessage struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
// Message is the chat completion message.
|
// Message is the chat completion message.
|
||||||
Message openai.ChatCompletionMessage `json:"message"`
|
Message openai.ChatCompletionMessage `json:"message"`
|
||||||
|
// Provider is the LLM provider used for the response.
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
// Model is the LLM model used for the response.
|
// Model is the LLM model used for the response.
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
// Voice is the voice used for TTS synthesis.
|
// Voice is the voice used for TTS synthesis.
|
||||||
@@ -516,6 +633,52 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve provider and model.
|
||||||
|
provider := req.Provider
|
||||||
|
if provider == "" {
|
||||||
|
provider = svc.cfg.DefaultModel.Provider
|
||||||
|
}
|
||||||
|
model := req.Model
|
||||||
|
if model == "" {
|
||||||
|
model = svc.cfg.DefaultModel.Model
|
||||||
|
}
|
||||||
|
llmc := svc.llms[provider]
|
||||||
|
if llmc == nil {
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx,
|
||||||
|
"unknown provider",
|
||||||
|
slog.String("provider", provider),
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf("unknown provider: %q", provider),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate model against provider allowed list, if configured.
|
||||||
|
if allowed, ok := svc.allowedModels[provider]; ok {
|
||||||
|
m, _, _ := strings.Cut(model, ":")
|
||||||
|
if _, ok := allowed[m]; !ok {
|
||||||
|
log.ErrorContext(
|
||||||
|
ctx,
|
||||||
|
"model not in allowed list",
|
||||||
|
slog.String("provider", provider),
|
||||||
|
slog.String("model", model),
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"model %q not allowed for provider %q",
|
||||||
|
model, provider,
|
||||||
|
),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set SSE headers.
|
// Set SSE headers.
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
@@ -534,19 +697,15 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get model.
|
|
||||||
var model = req.Model
|
|
||||||
if model == "" {
|
|
||||||
model = svc.llm.DefaultModel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start streaming LLM query.
|
// Start streaming LLM query.
|
||||||
var (
|
var (
|
||||||
events = make(chan llm.StreamEvent)
|
events = make(chan llm.StreamEvent)
|
||||||
llmErr error
|
llmErr error
|
||||||
)
|
)
|
||||||
go func() {
|
go func() {
|
||||||
llmErr = svc.llm.QueryStream(ctx, req.Messages, model, req.SystemMessage, 0, events)
|
llmErr = llmc.QueryStream(
|
||||||
|
ctx, req.Messages, model, req.SystemMessage, 0, events,
|
||||||
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Consume events and send as SSE.
|
// Consume events and send as SSE.
|
||||||
@@ -578,46 +737,127 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
last.Provider = provider
|
||||||
last.Model = model
|
last.Model = model
|
||||||
last.Voice = req.Voice
|
last.Voice = req.Voice
|
||||||
send(last)
|
send(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
// models returns available LLM models.
|
// ModelStatus represents the availability status of a model.
|
||||||
|
type ModelStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ModelStatusAvailable indicates the model is available for use.
|
||||||
|
ModelStatusAvailable ModelStatus = "available"
|
||||||
|
// ModelStatusNotFound indicates the model is configured but not found
|
||||||
|
// in the provider's model list.
|
||||||
|
ModelStatusNotFound ModelStatus = "not_found"
|
||||||
|
// ModelStatusProviderError indicates the provider could not be reached
|
||||||
|
// to verify the model.
|
||||||
|
ModelStatusProviderError ModelStatus = "provider_error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model represents a model in the /v1/models response.
|
||||||
|
type Model struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Status ModelStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models is the response format for the /v1/models endpoint.
|
||||||
|
type Models struct {
|
||||||
|
Providers map[string][]Model `json:"providers"`
|
||||||
|
DefaultProvider string `json:"default_provider"`
|
||||||
|
DefaultModel string `json:"default_model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// models returns available LLM models from all configured providers.
|
||||||
func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
|
func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
ctx = r.Context()
|
ctx = r.Context()
|
||||||
log = ctx.Value(logKey).(*slog.Logger)
|
log = ctx.Value(logKey).(*slog.Logger)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
providers = make(map[string][]Model, len(svc.llms))
|
||||||
)
|
)
|
||||||
|
|
||||||
models, err := svc.llm.ListModels(ctx)
|
cfg := make(map[string]config.ProviderConfig, len(svc.cfg.Providers))
|
||||||
if err != nil {
|
for _, pc := range svc.cfg.Providers {
|
||||||
log.ErrorContext(
|
cfg[pc.Name] = pc
|
||||||
ctx,
|
|
||||||
"failed to list models",
|
|
||||||
slog.Any("error", err),
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w,
|
|
||||||
"failed to list models",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for name, client := range svc.llms {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name string, c *llm.Client, pc config.ProviderConfig) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
var result = []Model{}
|
||||||
|
|
||||||
|
fetchCtx, cancel := context.WithTimeout(
|
||||||
|
ctx, modelsTimeout,
|
||||||
|
)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
models, err := c.ListModels(fetchCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.WarnContext(
|
||||||
|
ctx,
|
||||||
|
"failed to list models for provider",
|
||||||
|
slog.String("provider", name),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
for _, m := range pc.Models {
|
||||||
|
result = append(result, Model{
|
||||||
|
Model: m,
|
||||||
|
Status: ModelStatusProviderError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
providers[name] = result
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
available := make(map[string]struct{}, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
baseID, _, _ := strings.Cut(m.ID, ":")
|
||||||
|
available[baseID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var toCheck []string
|
||||||
|
if len(pc.Models) > 0 {
|
||||||
|
toCheck = pc.Models
|
||||||
|
} else {
|
||||||
|
toCheck = make([]string, 0, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
toCheck = append(toCheck, m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range toCheck {
|
||||||
|
base, _, _ := strings.Cut(m, ":")
|
||||||
|
mi := Model{Model: m}
|
||||||
|
if _, ok := available[base]; ok {
|
||||||
|
mi.Status = ModelStatusAvailable
|
||||||
|
} else {
|
||||||
|
mi.Status = ModelStatusNotFound
|
||||||
|
}
|
||||||
|
result = append(result, mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
providers[name] = result
|
||||||
|
mu.Unlock()
|
||||||
|
}(name, client, cfg[name])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(struct {
|
if err := json.NewEncoder(w).Encode(Models{
|
||||||
Models []openai.Model `json:"models"`
|
Providers: providers,
|
||||||
DefaultModel string `json:"default_model"`
|
DefaultProvider: svc.cfg.DefaultModel.Provider,
|
||||||
}{
|
DefaultModel: svc.cfg.DefaultModel.Model,
|
||||||
Models: models,
|
|
||||||
DefaultModel: svc.llm.DefaultModel(),
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.ErrorContext(
|
log.ErrorContext(ctx, "failed to encode models response",
|
||||||
ctx,
|
slog.Any("error", err))
|
||||||
"failed to encode models response",
|
|
||||||
slog.Any("error", err),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -970,3 +970,20 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Unavailable model indicators in settings select */
|
||||||
|
.settings-select option.model-unavailable-not-found {
|
||||||
|
color: #dc2626;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select option.model-unavailable-error {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select optgroup {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const STREAM_ENDPOINT = '/v1/chat/voice/stream';
|
|||||||
const ICONS_URL = '/static/icons.svg';
|
const ICONS_URL = '/static/icons.svg';
|
||||||
const MODELS_ENDPOINT = '/v1/models';
|
const MODELS_ENDPOINT = '/v1/models';
|
||||||
const MODEL_KEY = 'odidere_model';
|
const MODEL_KEY = 'odidere_model';
|
||||||
|
const PROVIDER_KEY = 'odidere_provider';
|
||||||
const STORAGE_KEY = 'odidere_history';
|
const STORAGE_KEY = 'odidere_history';
|
||||||
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
||||||
const VOICE_KEY = 'odidere_voice';
|
const VOICE_KEY = 'odidere_voice';
|
||||||
@@ -243,7 +244,9 @@ class Odidere {
|
|||||||
|
|
||||||
// Save selections on change.
|
// Save selections on change.
|
||||||
this.$model.addEventListener('change', () => {
|
this.$model.addEventListener('change', () => {
|
||||||
localStorage.setItem(MODEL_KEY, this.$model.value);
|
const $opt = this.$model.options[this.$model.selectedIndex];
|
||||||
|
localStorage.setItem(PROVIDER_KEY, $opt.dataset.provider);
|
||||||
|
localStorage.setItem(MODEL_KEY, $opt.dataset.model);
|
||||||
});
|
});
|
||||||
this.$voice.addEventListener('change', () => {
|
this.$voice.addEventListener('change', () => {
|
||||||
localStorage.setItem(VOICE_KEY, this.$voice.value);
|
localStorage.setItem(VOICE_KEY, this.$voice.value);
|
||||||
@@ -1348,13 +1351,18 @@ class Odidere {
|
|||||||
// ====================
|
// ====================
|
||||||
/**
|
/**
|
||||||
* #fetchModels fetches available models from the API and populates selectors.
|
* #fetchModels fetches available models from the API and populates selectors.
|
||||||
|
* The response now includes models with availability status and provider grouping.
|
||||||
*/
|
*/
|
||||||
async #fetchModels() {
|
async #fetchModels() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(MODELS_ENDPOINT);
|
const res = await fetch(MODELS_ENDPOINT);
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.#populateModels(data.models, data.default_model);
|
this.#populateModels(
|
||||||
|
data.providers,
|
||||||
|
data.default_provider,
|
||||||
|
data.default_model,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('failed to fetch models:', e);
|
console.error('failed to fetch models:', e);
|
||||||
this.#populateModelsFallback();
|
this.#populateModelsFallback();
|
||||||
@@ -1452,10 +1460,12 @@ class Odidere {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const $opt = this.$model.options[this.$model.selectedIndex];
|
||||||
const payload = {
|
const payload = {
|
||||||
messages,
|
messages,
|
||||||
voice: voice ?? this.$voice.value,
|
voice: voice ?? this.$voice.value,
|
||||||
model: this.$model.value,
|
provider: $opt?.dataset.provider,
|
||||||
|
model: $opt?.dataset.model,
|
||||||
};
|
};
|
||||||
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
||||||
if (systemMessage) {
|
if (systemMessage) {
|
||||||
@@ -1795,21 +1805,58 @@ class Odidere {
|
|||||||
// RENDER: SELECTS
|
// RENDER: SELECTS
|
||||||
// ====================
|
// ====================
|
||||||
/**
|
/**
|
||||||
* #populateModels populates the model selector with available options.
|
* #populateModels populates the model selector grouped by provider.
|
||||||
* @param {Array<{id: string}>} models
|
* Models are displayed with availability indicators:
|
||||||
|
* - Available: normal selectable option
|
||||||
|
* - Not found: red strikethrough, disabled
|
||||||
|
* - Provider error: grayed out, disabled
|
||||||
|
* @param {Object<string, Array<{model: string, status: string}>>} providers
|
||||||
|
* @param {string} defaultProvider
|
||||||
* @param {string} defaultModel
|
* @param {string} defaultModel
|
||||||
*/
|
*/
|
||||||
#populateModels(models, defaultModel) {
|
#populateModels(providers, defaultProvider, defaultModel) {
|
||||||
this.$model.innerHTML = '';
|
this.$model.innerHTML = '';
|
||||||
|
|
||||||
for (const m of models) {
|
// Sort provider names.
|
||||||
const $opt = this.document.createElement('option');
|
const sortedProviders = Object.keys(providers).sort();
|
||||||
$opt.value = m.id;
|
|
||||||
$opt.textContent = m.id;
|
// Create optgroups for each provider.
|
||||||
this.$model.appendChild($opt);
|
for (const provider of sortedProviders) {
|
||||||
|
const $optgroup = this.document.createElement('optgroup');
|
||||||
|
$optgroup.label = provider;
|
||||||
|
|
||||||
|
// Sort models within provider.
|
||||||
|
for (const m of providers[provider].sort((a, b) =>
|
||||||
|
a.model.localeCompare(b.model),
|
||||||
|
)) {
|
||||||
|
const $opt = this.document.createElement('option');
|
||||||
|
$opt.value = m.model;
|
||||||
|
$opt.dataset.provider = provider;
|
||||||
|
$opt.dataset.model = m.model;
|
||||||
|
|
||||||
|
switch (m.status) {
|
||||||
|
case 'available':
|
||||||
|
$opt.textContent = m.model;
|
||||||
|
break;
|
||||||
|
case 'not_found':
|
||||||
|
$opt.textContent = `~~${m.model}~~ (not found)`;
|
||||||
|
$opt.disabled = true;
|
||||||
|
$opt.classList.add('model-unavailable-not-found');
|
||||||
|
break;
|
||||||
|
case 'provider_error':
|
||||||
|
$opt.textContent = `${m.model} (provider error)`;
|
||||||
|
$opt.disabled = true;
|
||||||
|
$opt.classList.add('model-unavailable-error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$optgroup.appendChild($opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$model.appendChild($optgroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#loadModel(defaultModel);
|
this.#loadModel(defaultProvider, defaultModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1829,24 +1876,37 @@ class Odidere {
|
|||||||
/**
|
/**
|
||||||
* #loadModel restores the model selection from localStorage or uses the
|
* #loadModel restores the model selection from localStorage or uses the
|
||||||
* default.
|
* default.
|
||||||
|
* @param {string} defaultProvider
|
||||||
* @param {string} defaultModel
|
* @param {string} defaultModel
|
||||||
*/
|
*/
|
||||||
#loadModel(defaultModel) {
|
#loadModel(defaultProvider, defaultModel) {
|
||||||
const stored = localStorage.getItem(MODEL_KEY);
|
const storedProvider = localStorage.getItem(PROVIDER_KEY);
|
||||||
const escaped = stored ? CSS.escape(stored) : null;
|
const storedModel = localStorage.getItem(MODEL_KEY);
|
||||||
|
|
||||||
// Try stored value first, then default, then first available option.
|
// Try stored value first, then default, then first available option.
|
||||||
let selectedValue;
|
let $opt;
|
||||||
if (escaped && this.$model.querySelector(`option[value="${escaped}"]`)) {
|
if (storedProvider && storedModel) {
|
||||||
selectedValue = stored;
|
$opt = this.$model.querySelector(
|
||||||
} else if (defaultModel) {
|
`option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`,
|
||||||
selectedValue = defaultModel;
|
);
|
||||||
} else if (this.$model.options.length > 0) {
|
}
|
||||||
selectedValue = this.$model.options[0].value;
|
if (!$opt && defaultProvider && defaultModel) {
|
||||||
|
$opt = this.$model.querySelector(
|
||||||
|
`option[data-provider="${CSS.escape(defaultProvider)}"][data-model="${CSS.escape(defaultModel)}"]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!$opt && this.$model.options.length > 0) {
|
||||||
|
// Find first available (non-disabled) option.
|
||||||
|
for (const opt of this.$model.options) {
|
||||||
|
if (!opt.disabled) {
|
||||||
|
$opt = opt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedValue) {
|
if ($opt) {
|
||||||
this.$model.value = selectedValue;
|
this.$model.value = $opt.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sashabaranov/go-openai"
|
"github.com/sashabaranov/go-openai"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fn provides template functions available to argument templates.
|
// fn provides template functions available to argument templates.
|
||||||
@@ -36,25 +38,44 @@ var fn = template.FuncMap{
|
|||||||
// Tools are executed as subprocesses with templated arguments.
|
// Tools are executed as subprocesses with templated arguments.
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
// Name uniquely identifies the tool within a registry.
|
// Name uniquely identifies the tool within a registry.
|
||||||
Name string `yaml:"name"`
|
Name string
|
||||||
// Description explains the tool's purpose for the LLM.
|
// Description explains the tool's purpose for the LLM.
|
||||||
Description string `yaml:"description"`
|
Description string
|
||||||
// Command is the executable path or name.
|
// Command is the executable path or name.
|
||||||
Command string `yaml:"command"`
|
Command string
|
||||||
// Arguments are Go templates expanded with LLM-provided parameters.
|
// Arguments are Go templates expanded with LLM-provided parameters.
|
||||||
// Empty results after expansion are filtered out.
|
Arguments []string
|
||||||
Arguments []string `yaml:"arguments"`
|
|
||||||
// Parameters is a JSON Schema describing expected input from the LLM.
|
// Parameters is a JSON Schema describing expected input from the LLM.
|
||||||
Parameters map[string]any `yaml:"parameters"`
|
Parameters map[string]any
|
||||||
// Timeout limits execution time (e.g., "30s", "5m").
|
// timeout is the parsed execution duration limit.
|
||||||
// Empty means no timeout.
|
timeout time.Duration
|
||||||
Timeout string `yaml:"timeout"`
|
}
|
||||||
// timeout is the parsed duration, set during registry construction.
|
|
||||||
timeout time.Duration `yaml:"-"`
|
// NewTool creates a Tool from a ToolConfig.
|
||||||
|
func NewTool(cfg config.ToolConfig) (*Tool, error) {
|
||||||
|
var timeout time.Duration
|
||||||
|
if cfg.Timeout != "" {
|
||||||
|
var err error
|
||||||
|
timeout, err = time.ParseDuration(cfg.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"invalid timeout %q: %w", cfg.Timeout, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Tool{
|
||||||
|
Name: cfg.Name,
|
||||||
|
Description: cfg.Description,
|
||||||
|
Command: cfg.Command,
|
||||||
|
Arguments: cfg.Arguments,
|
||||||
|
Parameters: cfg.Parameters,
|
||||||
|
timeout: timeout,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI converts the tool to an OpenAI function definition for API calls.
|
// OpenAI converts the tool to an OpenAI function definition for API calls.
|
||||||
func (t Tool) OpenAI() openai.Tool {
|
func (t *Tool) OpenAI() openai.Tool {
|
||||||
return openai.Tool{
|
return openai.Tool{
|
||||||
Type: openai.ToolTypeFunction,
|
Type: openai.ToolTypeFunction,
|
||||||
Function: &openai.FunctionDefinition{
|
Function: &openai.FunctionDefinition{
|
||||||
@@ -69,13 +90,11 @@ func (t Tool) OpenAI() openai.Tool {
|
|||||||
// The args parameter should be a JSON object string; empty string or "{}"
|
// The args parameter should be a JSON object string; empty string or "{}"
|
||||||
// results in an empty data map. Templates producing empty strings are
|
// results in an empty data map. Templates producing empty strings are
|
||||||
// filtered from the result, allowing conditional arguments.
|
// filtered from the result, allowing conditional arguments.
|
||||||
func (t Tool) ParseArguments(args string) ([]string, error) {
|
func (t *Tool) ParseArguments(args string) ([]string, error) {
|
||||||
var data = map[string]any{}
|
var data = map[string]any{}
|
||||||
if args != "" && args != "{}" {
|
if args != "" && args != "{}" {
|
||||||
if err := json.Unmarshal([]byte(args), &data); err != nil {
|
if err := json.Unmarshal([]byte(args), &data); err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("invalid arguments JSON: %w", err)
|
||||||
"invalid arguments JSON: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,16 +102,12 @@ func (t Tool) ParseArguments(args string) ([]string, error) {
|
|||||||
for _, v := range t.Arguments {
|
for _, v := range t.Arguments {
|
||||||
tmpl, err := template.New("").Funcs(fn).Parse(v)
|
tmpl, err := template.New("").Funcs(fn).Parse(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("invalid template %q: %w", v, err)
|
||||||
"invalid template %q: %w", v, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := tmpl.Execute(&buf, data); err != nil {
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("execute template %q: %w", v, err)
|
||||||
"execute template %q: %w", v, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out empty strings (unused conditional arguments).
|
// Filter out empty strings (unused conditional arguments).
|
||||||
@@ -104,35 +119,8 @@ func (t Tool) ParseArguments(args string) ([]string, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks that the tool definition is complete and valid.
|
|
||||||
// It verifies required fields are present, the timeout (if specified)
|
|
||||||
// is parseable, and all argument templates are syntactically valid.
|
|
||||||
func (t Tool) Validate() error {
|
|
||||||
if t.Name == "" {
|
|
||||||
return fmt.Errorf("missing name")
|
|
||||||
}
|
|
||||||
if t.Description == "" {
|
|
||||||
return fmt.Errorf("missing description")
|
|
||||||
}
|
|
||||||
if t.Command == "" {
|
|
||||||
return fmt.Errorf("missing command")
|
|
||||||
}
|
|
||||||
if t.Timeout != "" {
|
|
||||||
if _, err := time.ParseDuration(t.Timeout); err != nil {
|
|
||||||
return fmt.Errorf("invalid timeout: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, arg := range t.Arguments {
|
|
||||||
if _, err := template.New("").Funcs(fn).Parse(arg); err != nil {
|
|
||||||
return fmt.Errorf("invalid argument template")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registry holds tools indexed by name and handles their execution.
|
// Registry holds tools indexed by name and handles their execution.
|
||||||
// It validates all tools at construction time to fail fast on
|
// It validates all tool definitions at construction time to fail fast on
|
||||||
// configuration errors.
|
// configuration errors.
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
tools map[string]*Tool
|
tools map[string]*Tool
|
||||||
@@ -140,30 +128,20 @@ type Registry struct {
|
|||||||
|
|
||||||
// NewRegistry creates a registry from the provided tool definitions.
|
// NewRegistry creates a registry from the provided tool definitions.
|
||||||
// Returns an error if any tool fails validation or if duplicate names exist.
|
// Returns an error if any tool fails validation or if duplicate names exist.
|
||||||
func NewRegistry(tools []Tool) (*Registry, error) {
|
func NewRegistry(tools []config.ToolConfig) (*Registry, error) {
|
||||||
var r = &Registry{
|
var r = &Registry{
|
||||||
tools: make(map[string]*Tool),
|
tools: make(map[string]*Tool),
|
||||||
}
|
}
|
||||||
for _, t := range tools {
|
for _, tc := range tools {
|
||||||
if err := t.Validate(); err != nil {
|
t, err := NewTool(tc)
|
||||||
return nil, fmt.Errorf("invalid tool: %v", err)
|
if err != nil {
|
||||||
}
|
return nil, fmt.Errorf("invalid tool %q: %w", tc.Name, err)
|
||||||
if t.Timeout != "" {
|
|
||||||
d, err := time.ParseDuration(t.Timeout)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"parse timeout: %v", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
t.timeout = d
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, exists := r.tools[t.Name]; exists {
|
if _, exists := r.tools[t.Name]; exists {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("duplicate tool name: %s", t.Name)
|
||||||
"duplicate tool name: %s", t.Name,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
r.tools[t.Name] = &t
|
r.tools[t.Name] = t
|
||||||
}
|
}
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
@@ -185,8 +163,8 @@ func (r *Registry) List() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs a tool by name with the provided JSON arguments.
|
// Execute runs a tool by name with the provided JSON arguments.
|
||||||
// It expands argument templates, executes the command as a subprocess,
|
// It expands argument templates, executes the command as a subprocess, and
|
||||||
// and returns stdout on success. The context can be used for cancellation;
|
// returns stdout on success. The context can be used for cancellation;
|
||||||
// tool-specific timeouts are applied on top of any context deadline.
|
// tool-specific timeouts are applied on top of any context deadline.
|
||||||
func (r *Registry) Execute(
|
func (r *Registry) Execute(
|
||||||
ctx context.Context, name string, args string,
|
ctx context.Context, name string, args string,
|
||||||
|
|||||||
@@ -1,229 +1,135 @@
|
|||||||
package tool
|
package tool
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestToolValidate(t *testing.T) {
|
func TestTool_OpenAI(t *testing.T) {
|
||||||
|
tool := &Tool{
|
||||||
|
Name: "test_tool",
|
||||||
|
Description: "test description",
|
||||||
|
Parameters: map[string]any{"type": "object"},
|
||||||
|
}
|
||||||
|
openaiTool := tool.OpenAI()
|
||||||
|
if openaiTool.Function.Name != "test_tool" {
|
||||||
|
t.Errorf("expected name test_tool, got %s", openaiTool.Function.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTool_ParseArguments(t *testing.T) {
|
||||||
|
tool := &Tool{
|
||||||
|
Arguments: []string{"--name {{.name}}", "{{if .flag}}--enabled{{end}}"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic",
|
||||||
|
args: `{"name": "test"}`,
|
||||||
|
expected: []string{"--name test"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with flag",
|
||||||
|
args: `{"name": "test", "flag": true}`,
|
||||||
|
expected: []string{"--name test", "--enabled"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := tool.ParseArguments(tt.args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseArguments failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.expected) {
|
||||||
|
t.Errorf("expected len %d, got %d", len(tt.expected), len(got))
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.expected[i] {
|
||||||
|
t.Errorf("expected %q, got %q", tt.expected[i], got[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTool(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
tool Tool
|
cfg config.ToolConfig
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid tool",
|
name: "valid",
|
||||||
tool: Tool{
|
cfg: config.ToolConfig{
|
||||||
Name: "echo",
|
|
||||||
Description: "echoes input",
|
|
||||||
Command: "echo",
|
|
||||||
Arguments: []string{"{{.message}}"},
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid tool with timeout",
|
|
||||||
tool: Tool{
|
|
||||||
Name: "slow",
|
|
||||||
Description: "slow command",
|
|
||||||
Command: "sleep",
|
|
||||||
Arguments: []string{"1"},
|
|
||||||
Timeout: "5s",
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing name",
|
|
||||||
tool: Tool{
|
|
||||||
Description: "test",
|
|
||||||
Command: "echo",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing description",
|
|
||||||
tool: Tool{
|
|
||||||
Name: "test",
|
|
||||||
Command: "echo",
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing command",
|
|
||||||
tool: Tool{
|
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Description: "test",
|
Description: "desc",
|
||||||
|
Command: "ls",
|
||||||
|
Timeout: "10s",
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid timeout",
|
name: "invalid timeout",
|
||||||
tool: Tool{
|
cfg: config.ToolConfig{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Description: "test",
|
Description: "desc",
|
||||||
Command: "echo",
|
Command: "ls",
|
||||||
Timeout: "invalid",
|
Timeout: "invalid",
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "invalid template",
|
|
||||||
tool: Tool{
|
|
||||||
Name: "test",
|
|
||||||
Description: "test",
|
|
||||||
Command: "echo",
|
|
||||||
Arguments: []string{"{{.unclosed"},
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := tt.tool.Validate()
|
tool, err := NewTool(tt.cfg)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf(
|
t.Errorf("NewTool() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
"Validate() error = %v, wantErr %v",
|
}
|
||||||
err, tt.wantErr,
|
if err == nil {
|
||||||
)
|
if tool.Name != tt.cfg.Name {
|
||||||
|
t.Errorf("expected name %s, got %s", tt.cfg.Name, tool.Name)
|
||||||
|
}
|
||||||
|
if tt.cfg.Timeout == "10s" && tool.timeout != 10*time.Second {
|
||||||
|
t.Errorf("expected timeout 10s, got %v", tool.timeout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolParseArguments(t *testing.T) {
|
func TestRegistry_NewRegistry(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
tool Tool
|
tools []config.ToolConfig
|
||||||
args string
|
|
||||||
want []string
|
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "simple substitution",
|
name: "valid",
|
||||||
tool: Tool{
|
tools: []config.ToolConfig{
|
||||||
Arguments: []string{"{{.message}}"},
|
{Name: "t1", Description: "d1", Command: "c1"},
|
||||||
},
|
{Name: "t2", Description: "d2", Command: "c2"},
|
||||||
args: `{"message": "hello"}`,
|
|
||||||
want: []string{"hello"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple arguments",
|
|
||||||
tool: Tool{
|
|
||||||
Arguments: []string{"-n", "{{.count}}", "{{.file}}"},
|
|
||||||
},
|
|
||||||
args: `{"count": "10", "file": "test.txt"}`,
|
|
||||||
want: []string{"-n", "10", "test.txt"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "conditional with if",
|
|
||||||
tool: Tool{
|
|
||||||
Arguments: []string{
|
|
||||||
"{{.required}}", "{{if .optional}}{{.optional}}{{end}}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
args: `{"required": "value"}`,
|
|
||||||
want: []string{"value"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json function",
|
|
||||||
tool: Tool{
|
|
||||||
Arguments: []string{`{{json .data}}`},
|
|
||||||
},
|
|
||||||
args: `{"data": {"key": "value"}}`,
|
|
||||||
want: []string{`{"key":"value"}`},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty JSON object",
|
|
||||||
tool: Tool{
|
|
||||||
Arguments: []string{"fixed"},
|
|
||||||
},
|
|
||||||
args: "{}",
|
|
||||||
want: []string{"fixed"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty string args",
|
|
||||||
tool: Tool{
|
|
||||||
Arguments: []string{"fixed"},
|
|
||||||
},
|
|
||||||
args: "",
|
|
||||||
want: []string{"fixed"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid JSON",
|
|
||||||
tool: Tool{
|
|
||||||
Arguments: []string{"{{.x}}"},
|
|
||||||
},
|
|
||||||
args: "not json",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := tt.tool.ParseArguments(tt.args)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf(
|
|
||||||
"ParseArguments() error = %v, wantErr %v",
|
|
||||||
err, tt.wantErr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !tt.wantErr && !slices.Equal(got, tt.want) {
|
|
||||||
t.Errorf(
|
|
||||||
"ParseArguments() = %v, want %v",
|
|
||||||
got, tt.want,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRegistry(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
tools []Tool
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid registry",
|
|
||||||
tools: []Tool{
|
|
||||||
{Name: "a", Description: "a", Command: "echo"},
|
|
||||||
{Name: "b", Description: "b", Command: "cat"},
|
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty registry",
|
name: "duplicate",
|
||||||
tools: []Tool{},
|
tools: []config.ToolConfig{
|
||||||
wantErr: false,
|
{Name: "t1", Description: "d1", Command: "c1"},
|
||||||
},
|
{Name: "t1", Description: "d2", Command: "c2"},
|
||||||
{
|
|
||||||
name: "duplicate names",
|
|
||||||
tools: []Tool{
|
|
||||||
{
|
|
||||||
Name: "dup",
|
|
||||||
Description: "first",
|
|
||||||
Command: "echo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "dup",
|
|
||||||
Description: "second",
|
|
||||||
Command: "cat",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid tool",
|
name: "invalid tool",
|
||||||
tools: []Tool{
|
tools: []config.ToolConfig{
|
||||||
{
|
{Name: "t1", Description: "d1", Command: "c1", Timeout: "invalid"},
|
||||||
Name: "",
|
|
||||||
Description: "missing name",
|
|
||||||
Command: "echo",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@@ -233,177 +139,8 @@ func TestNewRegistry(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
_, err := NewRegistry(tt.tools)
|
_, err := NewRegistry(tt.tools)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf(
|
t.Errorf("NewRegistry() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
"NewRegistry() error = %v, wantErr %v",
|
|
||||||
err, tt.wantErr,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistryGet(t *testing.T) {
|
|
||||||
r, err := NewRegistry([]Tool{
|
|
||||||
{Name: "echo", Description: "echo", Command: "echo"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRegistry() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tool, ok := r.Get("echo")
|
|
||||||
if !ok {
|
|
||||||
t.Error("Get(echo) returned false, want true")
|
|
||||||
}
|
|
||||||
if tool.Name != "echo" {
|
|
||||||
t.Errorf("Get(echo).Name = %q, want %q", tool.Name, "echo")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, ok = r.Get("nonexistent")
|
|
||||||
if ok {
|
|
||||||
t.Error("Get(nonexistent) returned true, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryList(t *testing.T) {
|
|
||||||
r, err := NewRegistry([]Tool{
|
|
||||||
{Name: "a", Description: "a", Command: "echo"},
|
|
||||||
{Name: "b", Description: "b", Command: "cat"},
|
|
||||||
{Name: "c", Description: "c", Command: "ls"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRegistry() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
names := r.List()
|
|
||||||
if len(names) != 3 {
|
|
||||||
t.Errorf("List() returned %d names, want 3", len(names))
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(names)
|
|
||||||
want := []string{"a", "b", "c"}
|
|
||||||
if !slices.Equal(names, want) {
|
|
||||||
t.Errorf("List() = %v, want %v", names, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryExecute(t *testing.T) {
|
|
||||||
r, err := NewRegistry([]Tool{
|
|
||||||
{
|
|
||||||
Name: "echo",
|
|
||||||
Description: "echo message",
|
|
||||||
Command: "echo",
|
|
||||||
Arguments: []string{"{{.message}}"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "cat",
|
|
||||||
Description: "cat stdin",
|
|
||||||
Command: "cat",
|
|
||||||
Arguments: []string{},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRegistry() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Successful execution.
|
|
||||||
out, err := r.Execute(ctx, "echo", `{"message": "hello world"}`)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Execute(echo) error = %v", err)
|
|
||||||
}
|
|
||||||
if out != "hello world\n" {
|
|
||||||
t.Errorf("Execute(echo) = %q, want %q", out, "hello world\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown tool.
|
|
||||||
_, err = r.Execute(ctx, "unknown", "{}")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Execute(unknown) expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid JSON arguments.
|
|
||||||
_, err = r.Execute(ctx, "echo", "not json")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Execute with invalid JSON expected error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryExecuteTimeout(t *testing.T) {
|
|
||||||
r, err := NewRegistry([]Tool{
|
|
||||||
{
|
|
||||||
Name: "slow",
|
|
||||||
Description: "slow command",
|
|
||||||
Command: "sleep",
|
|
||||||
Arguments: []string{"10"},
|
|
||||||
Timeout: "50ms",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRegistry() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
start := time.Now()
|
|
||||||
_, err = r.Execute(ctx, "slow", "{}")
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Execute(slow) expected timeout error, got nil")
|
|
||||||
}
|
|
||||||
if elapsed > time.Second {
|
|
||||||
t.Errorf(
|
|
||||||
"Execute(slow) took %v, expected ~50ms timeout",
|
|
||||||
elapsed,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryExecuteFailure(t *testing.T) {
|
|
||||||
r, err := NewRegistry([]Tool{
|
|
||||||
{
|
|
||||||
Name: "fail",
|
|
||||||
Description: "always fails",
|
|
||||||
Command: "false",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRegistry() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = r.Execute(context.Background(), "fail", "{}")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Execute(fail) expected error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolOpenAI(t *testing.T) {
|
|
||||||
tool := Tool{
|
|
||||||
Name: "test",
|
|
||||||
Description: "test tool",
|
|
||||||
Command: "echo",
|
|
||||||
Parameters: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"message": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "the message",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
openaiTool := tool.OpenAI()
|
|
||||||
if openaiTool.Function.Name != "test" {
|
|
||||||
t.Errorf(
|
|
||||||
"OpenAI().Function.Name = %q, want %q",
|
|
||||||
openaiTool.Function.Name, "test",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if openaiTool.Function.Description != "test tool" {
|
|
||||||
t.Errorf(
|
|
||||||
"OpenAI().Function.Description = %q, want %q",
|
|
||||||
openaiTool.Function.Description, "test tool",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user