From f5e72b1c7ad340cb3ad05da70f422b6bbddab9c7 Mon Sep 17 00:00:00 2001 From: dwrz Date: Fri, 19 Jun 2026 14:31:41 +0000 Subject: [PATCH] Add support for multiple providers --- config.example.yaml | 81 +++++- internal/config/config.go | 183 ++++++++++--- internal/config/config_test.go | 209 +++++++++------ internal/job/job.go | 147 +++-------- internal/job/job_test.go | 81 ++---- internal/llm/llm.go | 28 -- internal/llm/llm_test.go | 44 +--- internal/service/service.go | 366 +++++++++++++++++++++----- internal/service/static/main.css | 17 ++ internal/service/static/main.js | 108 ++++++-- internal/tool/tool.go | 116 ++++---- internal/tool/tool_test.go | 437 ++++++------------------------- 12 files changed, 948 insertions(+), 869 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 9ac1a19..4416b70 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,30 +1,49 @@ shutdown_timeout: 30s -server: - address: ":8080" +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" command: "curl" arguments: - "-s" - - "https://wttr.in/{{.location}}?format=j1" + - "https://wttr.in/{{ .location }}?format=j1" parameters: type: object properties: @@ -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" diff --git a/internal/config/config.go b/internal/config/config.go index 933f359..45ba5dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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}. - v := match[2 : len(match)-1] - return os.Getenv(v) - }, - ) + 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) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 93ada20..4f2f8bc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,15 +7,20 @@ 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 - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -71,9 +76,13 @@ func TestLoad(t *testing.T) { { name: "defaults applied", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -106,10 +115,14 @@ tts: { name: "env expansion", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 - key: ${TEST_LLM_KEY} + +providers: + - name: testprovider + url: http://localhost:8080 + key: ${TEST_LLM_KEY} stt: url: http://localhost:8178 @@ -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,10 +149,14 @@ tts: { name: "env expansion unset var becomes empty", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 - key: ${TEST_UNSET_VAR} + +providers: + - name: testprovider + url: http://localhost:8080 + key: ${TEST_UNSET_VAR} stt: url: http://localhost:8178 @@ -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,9 +187,13 @@ tts: config: ` shutdown_timeout: "not-a-duration" -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -178,10 +205,14 @@ tts: shouldErr: true, }, { - name: "missing llm model", + name: "missing default_model.provider", config: ` -llm: - url: http://localhost:8080 +default_model: + model: test-model + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -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,9 +264,13 @@ tts: { name: "missing stt url", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: "" @@ -226,9 +284,13 @@ tts: { name: "missing tts url", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -241,9 +303,13 @@ tts: { name: "missing tts voice", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -256,9 +322,13 @@ tts: { name: "config with tools", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -292,9 +362,13 @@ tools: { name: "invalid tool", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -388,9 +462,13 @@ func TestLoad_Jobs(t *testing.T) { { name: "valid job config", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -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,17 +509,18 @@ 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 - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -466,41 +544,16 @@ jobs: } }, }, - { - name: "disabled job", - config: ` -llm: - model: test-model - url: http://localhost:8080 - -stt: - url: http://localhost:8178 - -tts: - url: http://localhost:8880 - voice: af_heart - -jobs: - - name: disabled_job - schedule: "0 0 * * *" - enabled: false - task: "This should not run." -`, - check: func(t *testing.T, cfg *Config) { - if len(cfg.Jobs) != 1 { - t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs)) - } - if cfg.Jobs[0].IsEnabled() { - t.Error("IsEnabled() = true, want false") - } - }, - }, { name: "job missing name", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 @@ -518,9 +571,13 @@ jobs: { name: "job missing task", config: ` -llm: +default_model: + provider: testprovider model: test-model - url: http://localhost:8080 + +providers: + - name: testprovider + url: http://localhost:8080 stt: url: http://localhost:8178 diff --git a/internal/job/job.go b/internal/job/job.go index 0470f66..f8e34c9 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -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, diff --git a/internal/job/job_test.go b/internal/job/job_test.go index 17de5ed..74f910a 100644 --- a/internal/job/job_test.go +++ b/internal/job/job_test.go @@ -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") } diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 985b59a..a86ee7c 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -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) == "" { diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go index b3652bc..f1b2168 100644 --- a/internal/llm/llm_test.go +++ b/internal/llm/llm_test.go @@ -16,23 +16,13 @@ func TestConfigValidate(t *testing.T) { wantErr: true, }, { - name: "missing model", - cfg: Config{ - URL: "http://localhost:8080", - }, - wantErr: true, - }, - { - name: "missing URL", - cfg: Config{ - Model: "test-model", - }, + name: "missing URL", + cfg: Config{}, wantErr: true, }, { name: "invalid timeout", cfg: Config{ - Model: "test-model", URL: "http://localhost:8080", Timeout: "not-a-duration", }, @@ -41,15 +31,13 @@ func TestConfigValidate(t *testing.T) { { name: "valid minimal config", cfg: Config{ - Model: "test-model", - URL: "http://localhost:8080", + URL: "http://localhost:8080", }, wantErr: false, }, { 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,15 +90,13 @@ func TestNewClient(t *testing.T) { { name: "valid config without timeout", cfg: Config{ - Model: "test-model", - URL: "http://localhost:8080", + URL: "http://localhost:8080", }, wantErr: false, }, { 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", - ) - } -} diff --git a/internal/service/service.go b/internal/service/service.go index 95bcc63..6095ad0 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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/* @@ -45,15 +48,16 @@ var static embed.FS // Service is the main application coordinator. // It owns the HTTP server and all processing clients. type Service struct { - cfg *config.Config - cron *cron.Cron - jobs []*job.Job - llm *llm.Client - log *slog.Logger - mux *http.ServeMux - server *http.Server - tmpl *template.Template - tools *tool.Registry + cfg *config.Config + cron *cron.Cron + jobs []*job.Job + llms map[string]*llm.Client + log *slog.Logger + allowedModels map[string]map[string]struct{} + mux *http.ServeMux + server *http.Server + tmpl *template.Template + tools *tool.Registry } // New creates a Service from the provided configuration. @@ -72,19 +76,80 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) { } svc.tools = registry - // Create LLM client. - llmClient, err := llm.NewClient(cfg.LLM, registry, log) - if err != nil { - return nil, fmt.Errorf("create LLM client: %v", err) + // 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 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) - if err != nil { - log.ErrorContext( - ctx, - "failed to list models", - slog.Any("error", err), - ) - http.Error( - w, - "failed to list models", - http.StatusInternalServerError, - ) - return + 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.WarnContext( + ctx, + "failed to list models for provider", + slog.String("provider", name), + slog.Any("error", err), + ) + for _, m := range pc.Models { + result = append(result, Model{ + Model: m, + Status: ModelStatusProviderError, + }) + } + mu.Lock() + providers[name] = result + mu.Unlock() + return + } + + available := make(map[string]struct{}, len(models)) + for _, m := range models { + baseID, _, _ := strings.Cut(m.ID, ":") + available[baseID] = struct{}{} + } + + var toCheck []string + if len(pc.Models) > 0 { + toCheck = pc.Models + } else { + toCheck = make([]string, 0, len(models)) + for _, m := range models { + toCheck = append(toCheck, m.ID) + } + } + for _, m := range toCheck { + base, _, _ := strings.Cut(m, ":") + mi := Model{Model: m} + if _, ok := available[base]; ok { + mi.Status = ModelStatusAvailable + } else { + mi.Status = ModelStatusNotFound + } + result = append(result, mi) + } + + mu.Lock() + providers[name] = result + mu.Unlock() + }(name, client, cfg[name]) + } + wg.Wait() + w.Header().Set("Content-Type", "application/json") - 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)) } } diff --git a/internal/service/static/main.css b/internal/service/static/main.css index 1c9c694..e3a7067 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -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); +} diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 7e80c1e..c1be29a 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -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>} providers + * @param {string} defaultProvider * @param {string} defaultModel */ - #populateModels(models, defaultModel) { + #populateModels(providers, defaultProvider, defaultModel) { this.$model.innerHTML = ''; - for (const m of models) { - const $opt = this.document.createElement('option'); - $opt.value = m.id; - $opt.textContent = m.id; - this.$model.appendChild($opt); + // 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.model; + $opt.dataset.provider = provider; + $opt.dataset.model = m.model; + + switch (m.status) { + case 'available': + $opt.textContent = m.model; + break; + case 'not_found': + $opt.textContent = `~~${m.model}~~ (not found)`; + $opt.disabled = true; + $opt.classList.add('model-unavailable-not-found'); + break; + case 'provider_error': + $opt.textContent = `${m.model} (provider error)`; + $opt.disabled = true; + $opt.classList.add('model-unavailable-error'); + break; + } + + $optgroup.appendChild($opt); + } + + this.$model.appendChild($optgroup); } - this.#loadModel(defaultModel); + this.#loadModel(defaultProvider, defaultModel); } /** @@ -1829,24 +1876,37 @@ class Odidere { /** * #loadModel restores the model selection from localStorage or uses the * 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; } } diff --git a/internal/tool/tool.go b/internal/tool/tool.go index a6244c9..878d087 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -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) - if err != nil { - return nil, fmt.Errorf( - "parse timeout: %v", err, - ) - } - t.timeout = d + for _, tc := range tools { + t, err := NewTool(tc) + if err != nil { + 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, diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go index c274bac..2f3b7ec 100644 --- a/internal/tool/tool_test.go +++ b/internal/tool/tool_test.go @@ -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 + 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 - tool Tool + cfg config.ToolConfig wantErr bool }{ { - name: "valid tool", - tool: Tool{ - Name: "echo", - Description: "echoes input", - Command: "echo", - Arguments: []string{"{{.message}}"}, - }, - wantErr: false, - }, - { - name: "valid tool with timeout", - tool: Tool{ - Name: "slow", - Description: "slow command", - Command: "sleep", - Arguments: []string{"1"}, - Timeout: "5s", - }, - wantErr: false, - }, - { - name: "missing name", - tool: Tool{ - Description: "test", - Command: "echo", - }, - wantErr: true, - }, - { - name: "missing description", - tool: Tool{ - Name: "test", - Command: "echo", - }, - wantErr: true, - }, - { - name: "missing command", - tool: Tool{ + name: "valid", + cfg: config.ToolConfig{ Name: "test", - Description: "test", + Description: "desc", + Command: "ls", + Timeout: "10s", }, - wantErr: true, + wantErr: false, }, { 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", - ) - } -}