Add support for multiple providers

This commit is contained in:
dwrz
2026-06-19 14:31:41 +00:00
parent c71300b1bf
commit f5e72b1c7a
12 changed files with 948 additions and 869 deletions

View File

@@ -1,23 +1,42 @@
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"
@@ -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"

View File

@@ -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),
func(match string) string {
// Extract variable name from ${VAR}.
v := match[2 : len(match)-1] v := match[2 : len(match)-1]
return os.Getenv(v) 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)
} }

View File

@@ -7,14 +7,19 @@ 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
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -71,8 +76,12 @@ func TestLoad(t *testing.T) {
{ {
name: "defaults applied", name: "defaults applied",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -106,8 +115,12 @@ tts:
{ {
name: "env expansion", name: "env expansion",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
key: ${TEST_LLM_KEY} key: ${TEST_LLM_KEY}
@@ -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,8 +149,12 @@ 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
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
key: ${TEST_UNSET_VAR} key: ${TEST_UNSET_VAR}
@@ -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,8 +187,12 @@ tts:
config: ` config: `
shutdown_timeout: "not-a-duration" shutdown_timeout: "not-a-duration"
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -178,9 +205,13 @@ tts:
shouldErr: true, shouldErr: true,
}, },
{ {
name: "missing llm model", name: "missing default_model.provider",
config: ` config: `
llm: default_model:
model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -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,8 +264,12 @@ tts:
{ {
name: "missing stt url", name: "missing stt url",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -226,8 +284,12 @@ tts:
{ {
name: "missing tts url", name: "missing tts url",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -241,8 +303,12 @@ tts:
{ {
name: "missing tts voice", name: "missing tts voice",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -256,8 +322,12 @@ tts:
{ {
name: "config with tools", name: "config with tools",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -292,8 +362,12 @@ tools:
{ {
name: "invalid tool", name: "invalid tool",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -388,8 +462,12 @@ 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
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -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,16 +509,17 @@ 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
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -466,40 +544,15 @@ 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
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:
@@ -518,8 +571,12 @@ jobs:
{ {
name: "job missing task", name: "job missing task",
config: ` config: `
llm: default_model:
provider: testprovider
model: test-model model: test-model
providers:
- name: testprovider
url: http://localhost:8080 url: http://localhost:8080
stt: stt:

View File

@@ -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,

View File

@@ -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")
} }

View File

@@ -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) == "" {

View File

@@ -15,24 +15,14 @@ func TestConfigValidate(t *testing.T) {
cfg: Config{}, cfg: Config{},
wantErr: true, wantErr: true,
}, },
{
name: "missing model",
cfg: Config{
URL: "http://localhost:8080",
},
wantErr: true,
},
{ {
name: "missing URL", name: "missing URL",
cfg: Config{ 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,7 +31,6 @@ 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,
@@ -49,7 +38,6 @@ func TestConfigValidate(t *testing.T) {
{ {
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,7 +90,6 @@ 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,
@@ -112,7 +97,6 @@ func TestNewClient(t *testing.T) {
{ {
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",
)
}
}

View File

@@ -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/*
@@ -48,8 +51,9 @@ 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
allowedModels map[string]map[string]struct{}
mux *http.ServeMux mux *http.ServeMux
server *http.Server server *http.Server
tmpl *template.Template tmpl *template.Template
@@ -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))
for _, pc := range cfg.Providers {
client, err := llm.NewClient(
llm.Config{
Key: pc.Key,
SystemMessage: cfg.SystemMessage,
Timeout: pc.Timeout,
URL: pc.URL,
},
registry,
log,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("create LLM client: %v", err) 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))
for _, pc := range svc.cfg.Providers {
cfg[pc.Name] = pc
}
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 { if err != nil {
log.ErrorContext( log.WarnContext(
ctx, ctx,
"failed to list models", "failed to list models for provider",
slog.String("provider", name),
slog.Any("error", err), slog.Any("error", err),
) )
http.Error( for _, m := range pc.Models {
w, result = append(result, Model{
"failed to list models", Model: m,
http.StatusInternalServerError, Status: ModelStatusProviderError,
) })
}
mu.Lock()
providers[name] = result
mu.Unlock()
return 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),
)
} }
} }

View File

@@ -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);
}

View File

@@ -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 sortedProviders = Object.keys(providers).sort();
// Create optgroups for each provider.
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'); const $opt = this.document.createElement('option');
$opt.value = m.id; $opt.value = m.model;
$opt.textContent = m.id; $opt.dataset.provider = provider;
this.$model.appendChild($opt); $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;
} }
this.#loadModel(defaultModel); $optgroup.appendChild($opt);
}
this.$model.appendChild($optgroup);
}
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;
} }
} }

View File

@@ -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 t.Timeout != "" {
d, err := time.ParseDuration(t.Timeout)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("invalid tool %q: %w", tc.Name, err)
"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,

View File

@@ -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 { tests := []struct {
name string name string
tool Tool 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 {
name string
cfg config.ToolConfig
wantErr bool wantErr bool
}{ }{
{ {
name: "valid tool", name: "valid",
tool: Tool{ cfg: config.ToolConfig{
Name: "echo", Name: "test",
Description: "echoes input", Description: "desc",
Command: "echo", Command: "ls",
Arguments: []string{"{{.message}}"}, Timeout: "10s",
}, },
wantErr: false, 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",
Description: "test",
},
wantErr: true,
},
{ {
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",
)
}
}