Add support for multiple providers
This commit is contained in:
@@ -1,23 +1,42 @@
|
||||
shutdown_timeout: 30s
|
||||
|
||||
server:
|
||||
address: ":8080"
|
||||
|
||||
stt:
|
||||
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:
|
||||
url: "http://localhost:8880"
|
||||
voice: "af_heart"
|
||||
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:
|
||||
- name: get_weather
|
||||
description: "Get current weather for a location"
|
||||
@@ -34,11 +53,51 @@ tools:
|
||||
required:
|
||||
- location
|
||||
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:
|
||||
- name: "daily_summary"
|
||||
schedule: "0 9 * * *"
|
||||
enabled: true
|
||||
system_message: "You are an assistant."
|
||||
task: "Report the uptime on your host."
|
||||
timeout: "3m"
|
||||
|
||||
@@ -3,14 +3,11 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -30,6 +27,9 @@ func (cfg TTS) Validate() error {
|
||||
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.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.
|
||||
// The URL is passed to the browser via a meta tag; the browser calls
|
||||
// whisper-server directly.
|
||||
type STT struct {
|
||||
// URL is the base URL of the whisper-server.
|
||||
// For example, "http://localhost:8178".
|
||||
@@ -54,6 +52,118 @@ func (cfg STT) Validate() error {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -61,27 +171,27 @@ func (cfg STT) Validate() error {
|
||||
type Config struct {
|
||||
// Address is the host:port to listen on.
|
||||
Address string `yaml:"address"`
|
||||
|
||||
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
|
||||
// Defaults to "30s".
|
||||
ShutdownTimeout string `yaml:"shutdown_timeout"`
|
||||
// shutdownTimeout is the parsed duration, set during Load.
|
||||
shutdownTimeout time.Duration `yaml:"-"`
|
||||
|
||||
// LLM configures the language model client.
|
||||
LLM llm.Config `yaml:"llm"`
|
||||
// SystemMessage is the default system prompt prepended to all conversations.
|
||||
SystemMessage string `yaml:"system_message"`
|
||||
// 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 STT `yaml:"stt"`
|
||||
// Jobs defines scheduled autonomous tasks.
|
||||
Jobs []job.Config `yaml:"jobs"`
|
||||
Jobs []JobConfig `yaml:"jobs"`
|
||||
// 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 TTS `yaml:"tts"`
|
||||
}
|
||||
|
||||
// ApplyDefaults sets default values for optional configuration fields.
|
||||
// Called automatically by Load before validation.
|
||||
func (cfg *Config) ApplyDefaults() {
|
||||
if cfg.ShutdownTimeout == "" {
|
||||
cfg.ShutdownTimeout = "30s"
|
||||
@@ -97,14 +207,23 @@ func (cfg *Config) GetShutdownTimeout() time.Duration {
|
||||
}
|
||||
|
||||
// Validate checks that all required fields are present and valid.
|
||||
// Delegates to each subsection's Validate method.
|
||||
func (cfg *Config) Validate() error {
|
||||
if cfg.Address == "" {
|
||||
return fmt.Errorf("address required")
|
||||
}
|
||||
|
||||
if err := cfg.LLM.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid llm config: %w", err)
|
||||
if cfg.DefaultModel.Provider == "" {
|
||||
return fmt.Errorf("default_model.provider required")
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if _, err := time.ParseDuration(cfg.ShutdownTimeout); err != nil {
|
||||
return fmt.Errorf(
|
||||
"invalid shutdown_timeout %q: %w",
|
||||
cfg.ShutdownTimeout, err,
|
||||
)
|
||||
return fmt.Errorf("invalid shutdown_timeout %q: %w", cfg.ShutdownTimeout, err)
|
||||
}
|
||||
for i, jc := range cfg.Jobs {
|
||||
if err := jc.Validate(); err != nil {
|
||||
return fmt.Errorf("jobs[%d] invalid: %w", i, err)
|
||||
}
|
||||
}
|
||||
for i, t := range cfg.Tools {
|
||||
if err := t.Validate(); err != nil {
|
||||
for i, tc := range cfg.Tools {
|
||||
if err := tc.Validate(); err != nil {
|
||||
return fmt.Errorf("tools[%d] invalid: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads a YAML configuration file, expands environment variables,
|
||||
// applies defaults, and validates the result. Returns an error if the
|
||||
// file cannot be read, parsed, or contains invalid configuration.
|
||||
// applies defaults, and validates the result.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -142,23 +256,18 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
// Expand environment variables.
|
||||
// Unset variables are replaced with empty strings.
|
||||
re := regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
expanded := re.ReplaceAllStringFunc(
|
||||
string(data),
|
||||
func(match string) string {
|
||||
// Extract variable name from ${VAR}.
|
||||
expanded := re.ReplaceAllStringFunc(string(data), func(match string) string {
|
||||
v := match[2 : len(match)-1]
|
||||
return os.Getenv(v)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
cfg.ApplyDefaults()
|
||||
|
||||
cfg.ApplyDefaults()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %v", err)
|
||||
}
|
||||
|
||||
@@ -7,14 +7,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// validConfig returns a minimal valid YAML configuration.
|
||||
// validConfig returns a minimal valid YAML configuration using the
|
||||
// multi-provider format.
|
||||
func validConfig() string {
|
||||
return `
|
||||
address: ":9090"
|
||||
shutdown_timeout: "60s"
|
||||
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -71,8 +76,12 @@ func TestLoad(t *testing.T) {
|
||||
{
|
||||
name: "defaults applied",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -106,8 +115,12 @@ tts:
|
||||
{
|
||||
name: "env expansion",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
key: ${TEST_LLM_KEY}
|
||||
|
||||
@@ -122,10 +135,13 @@ tts:
|
||||
t.Setenv("TEST_LLM_KEY", "secret-api-key")
|
||||
},
|
||||
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(
|
||||
"LLM.Key = %q, want %q",
|
||||
cfg.LLM.Key, "secret-api-key",
|
||||
"Providers[0].Key = %q, want %q",
|
||||
cfg.Providers[0].Key, "secret-api-key",
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -133,8 +149,12 @@ tts:
|
||||
{
|
||||
name: "env expansion unset var becomes empty",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
key: ${TEST_UNSET_VAR}
|
||||
|
||||
@@ -146,17 +166,20 @@ tts:
|
||||
voice: af_heart
|
||||
`,
|
||||
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(
|
||||
"LLM.Key = %q, want empty",
|
||||
cfg.LLM.Key,
|
||||
"Providers[0].Key = %q, want empty",
|
||||
cfg.Providers[0].Key,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid yaml",
|
||||
config: `llm: [invalid yaml`,
|
||||
config: `providers: [invalid yaml`,
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
@@ -164,8 +187,12 @@ tts:
|
||||
config: `
|
||||
shutdown_timeout: "not-a-duration"
|
||||
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -178,9 +205,13 @@ tts:
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing llm model",
|
||||
name: "missing default_model.provider",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -193,11 +224,34 @@ tts:
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing llm url",
|
||||
name: "missing default_model.model",
|
||||
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
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
|
||||
stt:
|
||||
url: http://localhost:8178
|
||||
|
||||
@@ -210,8 +264,12 @@ tts:
|
||||
{
|
||||
name: "missing stt url",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -226,8 +284,12 @@ tts:
|
||||
{
|
||||
name: "missing tts url",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -241,8 +303,12 @@ tts:
|
||||
{
|
||||
name: "missing tts voice",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -256,8 +322,12 @@ tts:
|
||||
{
|
||||
name: "config with tools",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -292,8 +362,12 @@ tools:
|
||||
{
|
||||
name: "invalid tool",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -388,8 +462,12 @@ func TestLoad_Jobs(t *testing.T) {
|
||||
{
|
||||
name: "valid job config",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -402,7 +480,6 @@ tts:
|
||||
jobs:
|
||||
- name: daily_summary
|
||||
schedule: "0 9 * * *"
|
||||
enabled: true
|
||||
system_message: "You are a research assistant."
|
||||
task: "Summarize today's news about AI."
|
||||
timeout: "10m"
|
||||
@@ -432,16 +509,17 @@ jobs:
|
||||
if j.MaxIterations != 20 {
|
||||
t.Errorf("MaxIterations = %d, want 20", j.MaxIterations)
|
||||
}
|
||||
if !j.IsEnabled() {
|
||||
t.Error("IsEnabled() = false, want true")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "job with minimal config (defaults)",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
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",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
@@ -518,8 +571,12 @@ jobs:
|
||||
{
|
||||
name: "job missing task",
|
||||
config: `
|
||||
llm:
|
||||
default_model:
|
||||
provider: testprovider
|
||||
model: test-model
|
||||
|
||||
providers:
|
||||
- name: testprovider
|
||||
url: http://localhost:8080
|
||||
|
||||
stt:
|
||||
|
||||
@@ -15,99 +15,41 @@ import (
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
"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 is immutable after construction via New. All fields are private and
|
||||
// accessed through getter methods. This ensures that a Job's configuration
|
||||
// cannot change once it is handed to the service.
|
||||
type Job struct {
|
||||
// cron is the cron.Schedule derived from schedule during
|
||||
// construction. This is what the robfig/cron package uses to determine
|
||||
// when the job should fire.
|
||||
// cron is the cron.Schedule derived from
|
||||
// schedule during construction.
|
||||
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 *slog.Logger
|
||||
|
||||
// llm is the LLM client used to execute the job.
|
||||
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
|
||||
// unlimited.
|
||||
maxIterations int
|
||||
|
||||
// name uniquely identifies the job within the config. It is used for
|
||||
// logging, scheduling, and lookup.
|
||||
name string
|
||||
|
||||
// schedule is the original cron expression string from the config
|
||||
// (e.g. "0 9 * * *"). Stored for logging and display purposes.
|
||||
schedule string
|
||||
|
||||
// systemMessage is the per-job system prompt that overrides the global
|
||||
// LLM system message. Empty string means use the default.
|
||||
systemMessage string
|
||||
|
||||
// task is the user-level instruction passed to the LLM when the job
|
||||
// fires. This is the core of what the job does.
|
||||
task string
|
||||
|
||||
// timeout is the parsed execution time limit. Zero means no per-job
|
||||
// timeout (falls back to the global LLM timeout).
|
||||
timeout time.Duration
|
||||
@@ -115,12 +57,10 @@ type Job struct {
|
||||
|
||||
// New creates a new Job from a Config.
|
||||
// log is the structured logger used for job execution.
|
||||
// llm is the LLM client used to execute the job.
|
||||
func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// llmc is the LLM client used to execute the job.
|
||||
// model is the model ID (without provider prefix) to pass to the client.
|
||||
func New(cfg config.JobConfig, log *slog.Logger, llmc *llm.Client, model string) (*Job, error) {
|
||||
// Validation is handled by config.Config.Validate().
|
||||
sched, err := cron.ParseStandard(cfg.Schedule)
|
||||
if err != nil {
|
||||
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{
|
||||
cron: sched,
|
||||
enabled: enabled,
|
||||
log: log.With(slog.String("job", cfg.Name)),
|
||||
llm: llmClient,
|
||||
llm: llmc,
|
||||
model: model,
|
||||
maxIterations: cfg.MaxIterations,
|
||||
name: cfg.Name,
|
||||
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.
|
||||
func (j *Job) Cron() cron.Schedule {
|
||||
return j.cron
|
||||
}
|
||||
func (j *Job) Cron() cron.Schedule { return j.cron }
|
||||
|
||||
// IsEnabled returns whether the job should be scheduled.
|
||||
func (j *Job) IsEnabled() bool {
|
||||
return j.enabled
|
||||
}
|
||||
// MaxIterations returns the configured max iterations.
|
||||
func (j *Job) MaxIterations() int { return j.maxIterations }
|
||||
|
||||
// MaxIterations returns the configured max iterations. A value of 0
|
||||
// means unlimited (no cap on agent loop iterations).
|
||||
func (j *Job) MaxIterations() int {
|
||||
return j.maxIterations
|
||||
}
|
||||
// Model returns the model ID used by the job.
|
||||
func (j *Job) Model() string { return j.model }
|
||||
|
||||
// Name returns the job name.
|
||||
func (j *Job) Name() string {
|
||||
return j.name
|
||||
}
|
||||
func (j *Job) Name() string { return j.name }
|
||||
|
||||
// Schedule returns the cron schedule string.
|
||||
func (j *Job) Schedule() string {
|
||||
return j.schedule
|
||||
}
|
||||
// Schedule returns the job schedule string.
|
||||
func (j *Job) Schedule() string { return j.schedule }
|
||||
|
||||
// SystemMessage returns the job's system message.
|
||||
func (j *Job) SystemMessage() string {
|
||||
return j.systemMessage
|
||||
}
|
||||
func (j *Job) SystemMessage() string { return j.systemMessage }
|
||||
|
||||
// Task returns the job's task description.
|
||||
func (j *Job) Task() string {
|
||||
return j.task
|
||||
}
|
||||
func (j *Job) Task() string { return j.task }
|
||||
|
||||
// Timeout returns the job's timeout duration.
|
||||
func (j *Job) Timeout() time.Duration {
|
||||
return j.timeout
|
||||
}
|
||||
func (j *Job) Timeout() time.Duration { return j.timeout }
|
||||
|
||||
// Result holds the outcome of a job execution.
|
||||
type Result struct {
|
||||
@@ -212,7 +130,7 @@ type Result struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (j *Job) Run(ctx context.Context) Result {
|
||||
start := time.Now()
|
||||
@@ -237,13 +155,16 @@ func (j *Job) Run(ctx context.Context) Result {
|
||||
msgs, err := j.llm.Query(
|
||||
ctx,
|
||||
messages,
|
||||
"",
|
||||
j.model,
|
||||
j.SystemMessage(),
|
||||
j.MaxIterations(),
|
||||
)
|
||||
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
log.Error("job failed",
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"job failed",
|
||||
slog.Any("error", err),
|
||||
slog.Duration("duration", duration),
|
||||
)
|
||||
@@ -254,7 +175,9 @@ func (j *Job) Run(ctx context.Context) Result {
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
log.Error("job returned no messages",
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"job returned no messages",
|
||||
slog.Duration("duration", duration),
|
||||
)
|
||||
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.Int("messages", len(msgs)),
|
||||
)
|
||||
|
||||
return Result{
|
||||
Name: j.Name(),
|
||||
Duration: duration,
|
||||
|
||||
@@ -4,22 +4,24 @@ import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
config config.JobConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty config",
|
||||
config: Config{},
|
||||
config: config.JobConfig{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Schedule: "0 9 * * *",
|
||||
Task: "do something",
|
||||
},
|
||||
@@ -27,7 +29,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "missing task",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Schedule: "0 9 * * *",
|
||||
},
|
||||
@@ -35,7 +37,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "missing schedule",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
},
|
||||
@@ -43,7 +45,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "negative max iterations",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
@@ -53,7 +55,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "minimal valid config",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
@@ -62,7 +64,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "full valid config",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
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) {
|
||||
// Zero means unlimited
|
||||
cfg := Config{Name: "test"}
|
||||
cfg := config.JobConfig{Name: "test"}
|
||||
if got := cfg.MaxIterations; got != 0 {
|
||||
t.Errorf("default max iterations = %d, want 0", got)
|
||||
}
|
||||
@@ -126,7 +106,7 @@ func TestConfigMaxIterations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigTimeout(t *testing.T) {
|
||||
cfg := Config{Name: "test"}
|
||||
cfg := config.JobConfig{Name: "test"}
|
||||
if 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) {
|
||||
cfg := Config{
|
||||
cfg := config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
@@ -151,7 +131,7 @@ func TestNew(t *testing.T) {
|
||||
SystemMessage: "You are an assistant.",
|
||||
}
|
||||
|
||||
j, err := New(cfg, slog.Default(), nil)
|
||||
j, err := New(cfg, slog.Default(), nil, "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
@@ -162,9 +142,6 @@ func TestNew(t *testing.T) {
|
||||
if 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." {
|
||||
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 {
|
||||
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 {
|
||||
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) {
|
||||
cfg := Config{
|
||||
cfg := config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "not-a-cron",
|
||||
}
|
||||
|
||||
_, err := New(cfg, slog.Default(), nil)
|
||||
_, err := New(cfg, slog.Default(), nil, "test-model")
|
||||
if err == nil {
|
||||
t.Fatal("New() expected error for invalid schedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_InvalidTimeout(t *testing.T) {
|
||||
cfg := Config{
|
||||
cfg := config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
Timeout: "not-a-duration",
|
||||
}
|
||||
|
||||
_, err := New(cfg, slog.Default(), nil)
|
||||
_, err := New(cfg, slog.Default(), nil, "test-model")
|
||||
if err == nil {
|
||||
t.Fatal("New() expected error for invalid timeout")
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ import (
|
||||
type Config struct {
|
||||
// Key is the API key for authentication.
|
||||
Key string `yaml:"key"`
|
||||
// Model is the model identifier.
|
||||
Model string `yaml:"model"`
|
||||
// SystemMessage is prepended to all conversations.
|
||||
SystemMessage string `yaml:"system_message"`
|
||||
// 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.
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.Model == "" {
|
||||
return fmt.Errorf("missing model")
|
||||
}
|
||||
if cfg.Timeout != "" {
|
||||
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||
return fmt.Errorf("invalid timeout: %w", err)
|
||||
@@ -51,7 +46,6 @@ func (cfg Config) Validate() error {
|
||||
type Client struct {
|
||||
client *openai.Client
|
||||
log *slog.Logger
|
||||
model string
|
||||
registry *tool.Registry
|
||||
systemMessage string
|
||||
timeout time.Duration
|
||||
@@ -71,7 +65,6 @@ func NewClient(
|
||||
|
||||
llm := &Client{
|
||||
log: log,
|
||||
model: cfg.Model,
|
||||
systemMessage: cfg.SystemMessage,
|
||||
registry: registry,
|
||||
}
|
||||
@@ -115,17 +108,11 @@ func (c *Client) ListModels(ctx context.Context) ([]openai.Model, error) {
|
||||
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
|
||||
// maximum number of iterations.
|
||||
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
||||
|
||||
// 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.
|
||||
// maxIterations caps the number of agent loop iterations; a value of 0
|
||||
// means unlimited (no cap).
|
||||
@@ -141,11 +128,6 @@ func (c *Client) Query(
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Fallback to the default model.
|
||||
if model == "" {
|
||||
model = c.model
|
||||
}
|
||||
|
||||
// Use per-request system message if provided, otherwise fall back to config.
|
||||
effectiveMessage := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
@@ -273,11 +255,6 @@ func (c *Client) QueryStream(
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Fallback to the default model.
|
||||
if model == "" {
|
||||
model = c.model
|
||||
}
|
||||
|
||||
// Use per-request system message if provided, otherwise fall back to config.
|
||||
effectiveMessage := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
@@ -412,11 +389,6 @@ func (c *Client) QueryStream(
|
||||
result = fmt.Sprintf(
|
||||
`{"ok": false, "error": %q}`, err,
|
||||
)
|
||||
} else {
|
||||
c.log.Info(
|
||||
"called tool",
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
}
|
||||
// Content cannot be empty.
|
||||
if strings.TrimSpace(result) == "" {
|
||||
|
||||
@@ -15,24 +15,14 @@ func TestConfigValidate(t *testing.T) {
|
||||
cfg: Config{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing model",
|
||||
cfg: Config{
|
||||
URL: "http://localhost:8080",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing URL",
|
||||
cfg: Config{
|
||||
Model: "test-model",
|
||||
},
|
||||
cfg: Config{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid timeout",
|
||||
cfg: Config{
|
||||
Model: "test-model",
|
||||
URL: "http://localhost:8080",
|
||||
Timeout: "not-a-duration",
|
||||
},
|
||||
@@ -41,7 +31,6 @@ func TestConfigValidate(t *testing.T) {
|
||||
{
|
||||
name: "valid minimal config",
|
||||
cfg: Config{
|
||||
Model: "test-model",
|
||||
URL: "http://localhost:8080",
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -49,7 +38,6 @@ func TestConfigValidate(t *testing.T) {
|
||||
{
|
||||
name: "valid config with timeout",
|
||||
cfg: Config{
|
||||
Model: "test-model",
|
||||
URL: "http://localhost:8080",
|
||||
Timeout: "15m",
|
||||
},
|
||||
@@ -58,7 +46,6 @@ func TestConfigValidate(t *testing.T) {
|
||||
{
|
||||
name: "valid config with complex timeout",
|
||||
cfg: Config{
|
||||
Model: "test-model",
|
||||
URL: "http://localhost:8080",
|
||||
Timeout: "1h30m45s",
|
||||
},
|
||||
@@ -68,7 +55,6 @@ func TestConfigValidate(t *testing.T) {
|
||||
name: "valid full config",
|
||||
cfg: Config{
|
||||
Key: "sk-test-key",
|
||||
Model: "test-model",
|
||||
SystemMessage: "You are a helpful assistant.",
|
||||
Timeout: "30m",
|
||||
URL: "http://localhost:8080",
|
||||
@@ -104,7 +90,6 @@ func TestNewClient(t *testing.T) {
|
||||
{
|
||||
name: "valid config without timeout",
|
||||
cfg: Config{
|
||||
Model: "test-model",
|
||||
URL: "http://localhost:8080",
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -112,7 +97,6 @@ func TestNewClient(t *testing.T) {
|
||||
{
|
||||
name: "valid config with timeout",
|
||||
cfg: Config{
|
||||
Model: "test-model",
|
||||
URL: "http://localhost:8080",
|
||||
Timeout: "10m",
|
||||
},
|
||||
@@ -122,7 +106,6 @@ func TestNewClient(t *testing.T) {
|
||||
name: "valid config with all fields",
|
||||
cfg: Config{
|
||||
Key: "test-key",
|
||||
Model: "test-model",
|
||||
SystemMessage: "Test message",
|
||||
Timeout: "5m",
|
||||
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.
|
||||
// It coordinates the HTTP server, LLM client, and handles
|
||||
// It coordinates the HTTP server, LLM clients, and handles
|
||||
// graceful shutdown.
|
||||
package service
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +38,8 @@ const (
|
||||
logKey key = "log"
|
||||
idKey key = "id"
|
||||
ipKey key = "ip"
|
||||
|
||||
modelsTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
//go:embed all:static/*
|
||||
@@ -48,8 +51,9 @@ type Service struct {
|
||||
cfg *config.Config
|
||||
cron *cron.Cron
|
||||
jobs []*job.Job
|
||||
llm *llm.Client
|
||||
llms map[string]*llm.Client
|
||||
log *slog.Logger
|
||||
allowedModels map[string]map[string]struct{}
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
tmpl *template.Template
|
||||
@@ -72,19 +76,80 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
|
||||
}
|
||||
svc.tools = registry
|
||||
|
||||
// Create LLM client.
|
||||
llmClient, err := llm.NewClient(cfg.LLM, registry, log)
|
||||
// Create LLM clients for each provider.
|
||||
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 {
|
||||
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.
|
||||
jobs := make([]*job.Job, 0, len(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 {
|
||||
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)
|
||||
}
|
||||
@@ -132,8 +197,9 @@ func (svc *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
id = uuid.NewString()
|
||||
ip = func() string {
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
if idx := strings.Index(ip, ","); idx != -1 {
|
||||
return strings.TrimSpace(ip[:idx])
|
||||
before, _, found := strings.Cut(ip, ",")
|
||||
if found {
|
||||
return strings.TrimSpace(before)
|
||||
}
|
||||
return ip
|
||||
}
|
||||
@@ -196,8 +262,14 @@ func (svc *Service) Run(ctx context.Context) error {
|
||||
"starting odidere",
|
||||
slog.Group(
|
||||
"llm",
|
||||
slog.String("url", svc.cfg.LLM.URL),
|
||||
slog.String("model", svc.cfg.LLM.Model),
|
||||
slog.String(
|
||||
"default_provider",
|
||||
svc.cfg.DefaultModel.Provider,
|
||||
),
|
||||
slog.String(
|
||||
"default_model", svc.cfg.DefaultModel.Model,
|
||||
),
|
||||
slog.Int("providers", len(svc.cfg.Providers)),
|
||||
),
|
||||
slog.Group(
|
||||
"server",
|
||||
@@ -237,10 +309,6 @@ func (svc *Service) Run(ctx context.Context) error {
|
||||
// Register jobs with cron scheduler.
|
||||
svc.cron = cron.New()
|
||||
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() {
|
||||
result := j.Run(ctx)
|
||||
if result.Error != nil {
|
||||
@@ -374,6 +442,8 @@ func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
|
||||
type Request struct {
|
||||
// Messages is the conversation history.
|
||||
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 string `json:"model,omitempty"`
|
||||
// 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,
|
||||
// including tool calls and tool results.
|
||||
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 string `json:"used_model,omitempty"`
|
||||
// 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),
|
||||
)
|
||||
|
||||
// Get LLM response.
|
||||
var model = req.Model
|
||||
if model == "" {
|
||||
model = svc.llm.DefaultModel()
|
||||
// Resolve provider and model.
|
||||
provider := req.Provider
|
||||
if provider == "" {
|
||||
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 {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
@@ -450,12 +563,14 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
|
||||
ctx,
|
||||
"LLM response",
|
||||
slog.String("text", final.Content),
|
||||
slog.String("provider", provider),
|
||||
slog.String("model", model),
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(Response{
|
||||
Messages: msgs,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Voice: req.Voice,
|
||||
}); err != nil {
|
||||
@@ -473,6 +588,8 @@ type StreamMessage struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
// Message is the chat completion 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 string `json:"model,omitempty"`
|
||||
// Voice is the voice used for TTS synthesis.
|
||||
@@ -516,6 +633,52 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
@@ -534,19 +697,15 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Get model.
|
||||
var model = req.Model
|
||||
if model == "" {
|
||||
model = svc.llm.DefaultModel()
|
||||
}
|
||||
|
||||
// Start streaming LLM query.
|
||||
var (
|
||||
events = make(chan llm.StreamEvent)
|
||||
llmErr error
|
||||
)
|
||||
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.
|
||||
@@ -578,46 +737,127 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
last.Provider = provider
|
||||
last.Model = model
|
||||
last.Voice = req.Voice
|
||||
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) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
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 {
|
||||
log.ErrorContext(
|
||||
log.WarnContext(
|
||||
ctx,
|
||||
"failed to list models",
|
||||
"failed to list models for provider",
|
||||
slog.String("provider", name),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
http.Error(
|
||||
w,
|
||||
"failed to list models",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
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")
|
||||
if err := json.NewEncoder(w).Encode(struct {
|
||||
Models []openai.Model `json:"models"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
}{
|
||||
Models: models,
|
||||
DefaultModel: svc.llm.DefaultModel(),
|
||||
if err := json.NewEncoder(w).Encode(Models{
|
||||
Providers: providers,
|
||||
DefaultProvider: svc.cfg.DefaultModel.Provider,
|
||||
DefaultModel: svc.cfg.DefaultModel.Model,
|
||||
}); err != nil {
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"failed to encode models response",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
log.ErrorContext(ctx, "failed to encode models response",
|
||||
slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,3 +970,20 @@ body {
|
||||
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 MODELS_ENDPOINT = '/v1/models';
|
||||
const MODEL_KEY = 'odidere_model';
|
||||
const PROVIDER_KEY = 'odidere_provider';
|
||||
const STORAGE_KEY = 'odidere_history';
|
||||
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
||||
const VOICE_KEY = 'odidere_voice';
|
||||
@@ -243,7 +244,9 @@ class Odidere {
|
||||
|
||||
// Save selections on 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', () => {
|
||||
localStorage.setItem(VOICE_KEY, this.$voice.value);
|
||||
@@ -1348,13 +1351,18 @@ class Odidere {
|
||||
// ====================
|
||||
/**
|
||||
* #fetchModels fetches available models from the API and populates selectors.
|
||||
* The response now includes models with availability status and provider grouping.
|
||||
*/
|
||||
async #fetchModels() {
|
||||
try {
|
||||
const res = await fetch(MODELS_ENDPOINT);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
this.#populateModels(data.models, data.default_model);
|
||||
this.#populateModels(
|
||||
data.providers,
|
||||
data.default_provider,
|
||||
data.default_model,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('failed to fetch models:', e);
|
||||
this.#populateModelsFallback();
|
||||
@@ -1452,10 +1460,12 @@ class Odidere {
|
||||
);
|
||||
}
|
||||
|
||||
const $opt = this.$model.options[this.$model.selectedIndex];
|
||||
const payload = {
|
||||
messages,
|
||||
voice: voice ?? this.$voice.value,
|
||||
model: this.$model.value,
|
||||
provider: $opt?.dataset.provider,
|
||||
model: $opt?.dataset.model,
|
||||
};
|
||||
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
||||
if (systemMessage) {
|
||||
@@ -1795,21 +1805,58 @@ class Odidere {
|
||||
// RENDER: SELECTS
|
||||
// ====================
|
||||
/**
|
||||
* #populateModels populates the model selector with available options.
|
||||
* @param {Array<{id: string}>} models
|
||||
* #populateModels populates the model selector grouped by provider.
|
||||
* 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
|
||||
*/
|
||||
#populateModels(models, defaultModel) {
|
||||
#populateModels(providers, defaultProvider, defaultModel) {
|
||||
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');
|
||||
$opt.value = m.id;
|
||||
$opt.textContent = m.id;
|
||||
this.$model.appendChild($opt);
|
||||
$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;
|
||||
}
|
||||
|
||||
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
|
||||
* default.
|
||||
* @param {string} defaultProvider
|
||||
* @param {string} defaultModel
|
||||
*/
|
||||
#loadModel(defaultModel) {
|
||||
const stored = localStorage.getItem(MODEL_KEY);
|
||||
const escaped = stored ? CSS.escape(stored) : null;
|
||||
#loadModel(defaultProvider, defaultModel) {
|
||||
const storedProvider = localStorage.getItem(PROVIDER_KEY);
|
||||
const storedModel = localStorage.getItem(MODEL_KEY);
|
||||
|
||||
// Try stored value first, then default, then first available option.
|
||||
let selectedValue;
|
||||
if (escaped && this.$model.querySelector(`option[value="${escaped}"]`)) {
|
||||
selectedValue = stored;
|
||||
} else if (defaultModel) {
|
||||
selectedValue = defaultModel;
|
||||
} else if (this.$model.options.length > 0) {
|
||||
selectedValue = this.$model.options[0].value;
|
||||
let $opt;
|
||||
if (storedProvider && storedModel) {
|
||||
$opt = this.$model.querySelector(
|
||||
`option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`,
|
||||
);
|
||||
}
|
||||
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) {
|
||||
this.$model.value = selectedValue;
|
||||
if ($opt) {
|
||||
this.$model.value = $opt.value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
)
|
||||
|
||||
// fn provides template functions available to argument templates.
|
||||
@@ -36,25 +38,44 @@ var fn = template.FuncMap{
|
||||
// Tools are executed as subprocesses with templated arguments.
|
||||
type Tool struct {
|
||||
// Name uniquely identifies the tool within a registry.
|
||||
Name string `yaml:"name"`
|
||||
Name string
|
||||
// Description explains the tool's purpose for the LLM.
|
||||
Description string `yaml:"description"`
|
||||
Description string
|
||||
// Command is the executable path or name.
|
||||
Command string `yaml:"command"`
|
||||
Command string
|
||||
// Arguments are Go templates expanded with LLM-provided parameters.
|
||||
// Empty results after expansion are filtered out.
|
||||
Arguments []string `yaml:"arguments"`
|
||||
Arguments []string
|
||||
// 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").
|
||||
// Empty means no timeout.
|
||||
Timeout string `yaml:"timeout"`
|
||||
// timeout is the parsed duration, set during registry construction.
|
||||
timeout time.Duration `yaml:"-"`
|
||||
Parameters map[string]any
|
||||
// timeout is the parsed execution duration limit.
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (t Tool) OpenAI() openai.Tool {
|
||||
func (t *Tool) OpenAI() openai.Tool {
|
||||
return openai.Tool{
|
||||
Type: openai.ToolTypeFunction,
|
||||
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 "{}"
|
||||
// results in an empty data map. Templates producing empty strings are
|
||||
// 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{}
|
||||
if args != "" && args != "{}" {
|
||||
if err := json.Unmarshal([]byte(args), &data); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid arguments JSON: %w", err,
|
||||
)
|
||||
return nil, fmt.Errorf("invalid arguments JSON: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +102,12 @@ func (t Tool) ParseArguments(args string) ([]string, error) {
|
||||
for _, v := range t.Arguments {
|
||||
tmpl, err := template.New("").Funcs(fn).Parse(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid template %q: %w", v, err,
|
||||
)
|
||||
return nil, fmt.Errorf("invalid template %q: %w", v, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"execute template %q: %w", v, err,
|
||||
)
|
||||
return nil, fmt.Errorf("execute template %q: %w", v, err)
|
||||
}
|
||||
|
||||
// Filter out empty strings (unused conditional arguments).
|
||||
@@ -104,35 +119,8 @@ func (t Tool) ParseArguments(args string) ([]string, error) {
|
||||
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.
|
||||
// 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.
|
||||
type Registry struct {
|
||||
tools map[string]*Tool
|
||||
@@ -140,30 +128,20 @@ type Registry struct {
|
||||
|
||||
// NewRegistry creates a registry from the provided tool definitions.
|
||||
// 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{
|
||||
tools: make(map[string]*Tool),
|
||||
}
|
||||
for _, t := range tools {
|
||||
if err := t.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool: %v", err)
|
||||
}
|
||||
if t.Timeout != "" {
|
||||
d, err := time.ParseDuration(t.Timeout)
|
||||
for _, tc := range tools {
|
||||
t, err := NewTool(tc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parse timeout: %v", err,
|
||||
)
|
||||
}
|
||||
t.timeout = d
|
||||
return nil, fmt.Errorf("invalid tool %q: %w", tc.Name, err)
|
||||
}
|
||||
|
||||
if _, exists := r.tools[t.Name]; exists {
|
||||
return nil, fmt.Errorf(
|
||||
"duplicate tool name: %s", t.Name,
|
||||
)
|
||||
return nil, fmt.Errorf("duplicate tool name: %s", t.Name)
|
||||
}
|
||||
r.tools[t.Name] = &t
|
||||
r.tools[t.Name] = t
|
||||
}
|
||||
|
||||
return r, nil
|
||||
@@ -185,8 +163,8 @@ func (r *Registry) List() []string {
|
||||
}
|
||||
|
||||
// Execute runs a tool by name with the provided JSON arguments.
|
||||
// It expands argument templates, executes the command as a subprocess,
|
||||
// and returns stdout on success. The context can be used for cancellation;
|
||||
// It expands argument templates, executes the command as a subprocess, and
|
||||
// returns stdout on success. The context can be used for cancellation;
|
||||
// tool-specific timeouts are applied on top of any context deadline.
|
||||
func (r *Registry) Execute(
|
||||
ctx context.Context, name string, args string,
|
||||
|
||||
@@ -1,229 +1,135 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"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
|
||||
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
|
||||
}{
|
||||
{
|
||||
name: "valid tool",
|
||||
tool: Tool{
|
||||
Name: "echo",
|
||||
Description: "echoes input",
|
||||
Command: "echo",
|
||||
Arguments: []string{"{{.message}}"},
|
||||
name: "valid",
|
||||
cfg: config.ToolConfig{
|
||||
Name: "test",
|
||||
Description: "desc",
|
||||
Command: "ls",
|
||||
Timeout: "10s",
|
||||
},
|
||||
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",
|
||||
tool: Tool{
|
||||
cfg: config.ToolConfig{
|
||||
Name: "test",
|
||||
Description: "test",
|
||||
Command: "echo",
|
||||
Description: "desc",
|
||||
Command: "ls",
|
||||
Timeout: "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid template",
|
||||
tool: Tool{
|
||||
Name: "test",
|
||||
Description: "test",
|
||||
Command: "echo",
|
||||
Arguments: []string{"{{.unclosed"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.tool.Validate()
|
||||
tool, err := NewTool(tt.cfg)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf(
|
||||
"Validate() error = %v, wantErr %v",
|
||||
err, tt.wantErr,
|
||||
)
|
||||
t.Errorf("NewTool() 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 {
|
||||
name string
|
||||
tool Tool
|
||||
args string
|
||||
want []string
|
||||
tools []config.ToolConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple substitution",
|
||||
tool: Tool{
|
||||
Arguments: []string{"{{.message}}"},
|
||||
},
|
||||
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"},
|
||||
name: "valid",
|
||||
tools: []config.ToolConfig{
|
||||
{Name: "t1", Description: "d1", Command: "c1"},
|
||||
{Name: "t2", Description: "d2", Command: "c2"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty registry",
|
||||
tools: []Tool{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate names",
|
||||
tools: []Tool{
|
||||
{
|
||||
Name: "dup",
|
||||
Description: "first",
|
||||
Command: "echo",
|
||||
},
|
||||
{
|
||||
Name: "dup",
|
||||
Description: "second",
|
||||
Command: "cat",
|
||||
},
|
||||
name: "duplicate",
|
||||
tools: []config.ToolConfig{
|
||||
{Name: "t1", Description: "d1", Command: "c1"},
|
||||
{Name: "t1", Description: "d2", Command: "c2"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid tool",
|
||||
tools: []Tool{
|
||||
{
|
||||
Name: "",
|
||||
Description: "missing name",
|
||||
Command: "echo",
|
||||
},
|
||||
tools: []config.ToolConfig{
|
||||
{Name: "t1", Description: "d1", Command: "c1", Timeout: "invalid"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
@@ -233,177 +139,8 @@ func TestNewRegistry(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewRegistry(tt.tools)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf(
|
||||
"NewRegistry() error = %v, wantErr %v",
|
||||
err, tt.wantErr,
|
||||
)
|
||||
t.Errorf("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