Add support for multiple providers

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

View File

@@ -1,23 +1,42 @@
shutdown_timeout: 30s
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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