Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,4 @@ Full reference: `.env.template` and `config/config.yaml`
- **Metrics:** `METRICS_ENABLED` (false), `METRICS_ENDPOINT` (/metrics)
- **Guardrails:** Configured via `config/config.yaml` only (except `GUARDRAILS_ENABLED` env var)
- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `ORACLE_MODELS` (comma-separated Oracle fallback model inventory), `OLLAMA_BASE_URL`, `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token)
- **Provider model metadata:** `providers.<name>.models` accepts either model IDs (strings) or `{id, metadata}` objects. When `metadata` is supplied (`display_name`, `context_window`, `max_output_tokens`, `modes`, `capabilities`, `pricing`, …) it is merged onto the remote ai-model-list entry during enrichment, with operator values winning per-field. Primary use case: advertising context windows, capabilities, and pricing for local models (Ollama) and other custom endpoints whose IDs are not in the upstream registry.
24 changes: 24 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,27 @@ providers:
# type: "openai"
# base_url: "https://api.deepseek.com/v1"
# api_key: "${DEEPSEEK_API_KEY}"

# Example: local Ollama server with explicit per-model metadata.
# Use the rich entry form to declare context_window, pricing, capabilities,
# etc. for models that aren't in the upstream ai-model-list registry. Plain
# string entries still work and produce unenriched models as before.
# Declared fields are merged onto any remote-registry entry; operator values
# win per-field.
# nippur:
# type: "ollama"
# base_url: "http://127.0.0.1:8080/v1"
# models:
# - id: GLM-4.7-Flash
# metadata:
# display_name: "GLM 4.7 Flash (local)"
# context_window: 131072
# max_output_tokens: 8192
# modes: ["chat"]
# capabilities:
# tools: true
# pricing:
# currency: USD
# input_per_mtok: 0
# output_per_mtok: 0
# - Gemma4-31B
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ type RawProviderConfig struct {
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"`
APIVersion string `yaml:"api_version"`
Models []string `yaml:"models"`
Models []RawProviderModel `yaml:"models"`
Resilience *RawResilienceConfig `yaml:"resilience"`
}

Expand Down
93 changes: 93 additions & 0 deletions config/provider_models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package config

import (
"fmt"
"strings"

"gopkg.in/yaml.v3"

"gomodel/internal/core"
)

// RawProviderModel is a single entry under providers.<name>.models. It supports
// two YAML shapes so operators can opt into rich metadata without churning
// simple configs:
//
// models:
// - some-model-id # bare string
// - id: local-model # mapping with optional metadata
// metadata:
// context_window: 131072
// capabilities:
// tools: true
//
// Metadata is merged onto whatever the remote model registry supplies, with
// config-declared fields taking precedence. This lets local providers (Ollama,
// custom OpenAI-compatible endpoints) advertise their context windows, pricing,
// and capabilities via /v1/models even when the remote registry has no entry.
type RawProviderModel struct {
ID string `yaml:"id"`
Metadata *core.ModelMetadata `yaml:"metadata,omitempty"`
}

// UnmarshalYAML accepts either a bare string (model ID) or a mapping with id and metadata.
func (m *RawProviderModel) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var id string
if err := node.Decode(&id); err != nil {
return fmt.Errorf("provider model: %w", err)
}
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("provider model: id is required")
}
m.ID = id
return nil
case yaml.MappingNode:
type rawAlias RawProviderModel
var alias rawAlias
if err := node.Decode(&alias); err != nil {
return fmt.Errorf("provider model: %w", err)
}
*m = RawProviderModel(alias)
m.ID = strings.TrimSpace(m.ID)
if m.ID == "" {
return fmt.Errorf("provider model: id is required")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return nil
default:
return fmt.Errorf("provider model: expected scalar or mapping, got kind %d", node.Kind)
}
}
Comment thread
SantiagoDePolonia marked this conversation as resolved.

// ProviderModelIDs returns the ID of each model entry, preserving order and
// dropping entries with empty IDs.
func ProviderModelIDs(models []RawProviderModel) []string {
if len(models) == 0 {
return nil
}
ids := make([]string, 0, len(models))
for _, m := range models {
if m.ID != "" {
ids = append(ids, m.ID)
}
}
return ids
}

// ProviderModelMetadataOverrides returns id -> metadata for entries with
// non-nil Metadata. Returns nil if no entries declare metadata.
func ProviderModelMetadataOverrides(models []RawProviderModel) map[string]*core.ModelMetadata {
var out map[string]*core.ModelMetadata
for _, m := range models {
if m.ID == "" || m.Metadata == nil {
continue
}
if out == nil {
out = make(map[string]*core.ModelMetadata)
}
out[m.ID] = m.Metadata
}
return out
}
183 changes: 183 additions & 0 deletions config/provider_models_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package config

import (
"testing"

"gopkg.in/yaml.v3"

"gomodel/internal/core"
)

func TestRawProviderModel_UnmarshalYAML_String(t *testing.T) {
const data = `- some-model`
var models []RawProviderModel
if err := yaml.Unmarshal([]byte(data), &models); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(models) != 1 {
t.Fatalf("len = %d, want 1", len(models))
}
if models[0].ID != "some-model" {
t.Errorf("ID = %q, want some-model", models[0].ID)
}
if models[0].Metadata != nil {
t.Errorf("Metadata = %+v, want nil", models[0].Metadata)
}
}

func TestRawProviderModel_UnmarshalYAML_MappingWithMetadata(t *testing.T) {
const data = `
- id: local-model
metadata:
display_name: Local Model
context_window: 131072
max_output_tokens: 8192
modes: [chat]
capabilities:
tools: true
vision: false
pricing:
currency: USD
input_per_mtok: 0
output_per_mtok: 0
`
var models []RawProviderModel
if err := yaml.Unmarshal([]byte(data), &models); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(models) != 1 {
t.Fatalf("len = %d, want 1", len(models))
}
m := models[0]
if m.ID != "local-model" {
t.Errorf("ID = %q, want local-model", m.ID)
}
if m.Metadata == nil {
t.Fatal("Metadata = nil, want non-nil")
}
if m.Metadata.DisplayName != "Local Model" {
t.Errorf("DisplayName = %q", m.Metadata.DisplayName)
}
if m.Metadata.ContextWindow == nil || *m.Metadata.ContextWindow != 131072 {
t.Errorf("ContextWindow = %v, want 131072", m.Metadata.ContextWindow)
}
if m.Metadata.MaxOutputTokens == nil || *m.Metadata.MaxOutputTokens != 8192 {
t.Errorf("MaxOutputTokens = %v, want 8192", m.Metadata.MaxOutputTokens)
}
if got := m.Metadata.Capabilities["tools"]; !got {
t.Errorf("Capabilities[tools] = %v, want true", got)
}
if m.Metadata.Pricing == nil || m.Metadata.Pricing.Currency != "USD" {
t.Errorf("Pricing = %+v", m.Metadata.Pricing)
}
}

func TestRawProviderModel_UnmarshalYAML_MixedList(t *testing.T) {
const data = `
- plain-id
- id: rich-model
metadata:
context_window: 4096
`
var models []RawProviderModel
if err := yaml.Unmarshal([]byte(data), &models); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(models) != 2 {
t.Fatalf("len = %d, want 2", len(models))
}
if models[0].ID != "plain-id" || models[0].Metadata != nil {
t.Errorf("models[0] = %+v", models[0])
}
if models[1].ID != "rich-model" || models[1].Metadata == nil {
t.Errorf("models[1] = %+v", models[1])
}
}

func TestRawProviderModel_UnmarshalYAML_RejectsMappingWithoutID(t *testing.T) {
const data = `
- metadata:
context_window: 1024
`
var models []RawProviderModel
err := yaml.Unmarshal([]byte(data), &models)
if err == nil {
t.Fatal("expected error for mapping without id, got nil")
}
}

func TestRawProviderModel_UnmarshalYAML_RejectsEmptyScalar(t *testing.T) {
const data = `- ""`
var models []RawProviderModel
err := yaml.Unmarshal([]byte(data), &models)
if err == nil {
t.Fatal("expected error for empty scalar id, got nil")
}
}

func TestRawProviderModel_UnmarshalYAML_RejectsWhitespaceOnlyScalar(t *testing.T) {
const data = `- " "`
var models []RawProviderModel
err := yaml.Unmarshal([]byte(data), &models)
if err == nil {
t.Fatal("expected error for whitespace-only scalar id, got nil")
}
}

func TestRawProviderModel_UnmarshalYAML_RejectsWhitespaceOnlyMappingID(t *testing.T) {
const data = `
- id: " "
metadata:
context_window: 1024
`
var models []RawProviderModel
err := yaml.Unmarshal([]byte(data), &models)
if err == nil {
t.Fatal("expected error for whitespace-only mapping id, got nil")
}
}

func TestRawProviderModel_UnmarshalYAML_TrimsScalar(t *testing.T) {
const data = `- " some-model "`
var models []RawProviderModel
if err := yaml.Unmarshal([]byte(data), &models); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if models[0].ID != "some-model" {
t.Errorf("ID = %q, want some-model (trimmed)", models[0].ID)
}
}

func TestProviderModelIDs(t *testing.T) {
models := []RawProviderModel{
{ID: "a"},
{ID: "b", Metadata: &core.ModelMetadata{}},
{ID: ""}, // filtered
}
ids := ProviderModelIDs(models)
if len(ids) != 2 || ids[0] != "a" || ids[1] != "b" {
t.Errorf("ids = %v, want [a b]", ids)
}
if got := ProviderModelIDs(nil); got != nil {
t.Errorf("nil input -> %v, want nil", got)
}
}

func TestProviderModelMetadataOverrides(t *testing.T) {
ctxWindow := 2048
models := []RawProviderModel{
{ID: "plain"},
{ID: "rich", Metadata: &core.ModelMetadata{ContextWindow: &ctxWindow}},
{ID: "", Metadata: &core.ModelMetadata{ContextWindow: &ctxWindow}}, // filtered
}
overrides := ProviderModelMetadataOverrides(models)
if len(overrides) != 1 {
t.Fatalf("len = %d, want 1", len(overrides))
}
if overrides["rich"].ContextWindow == nil || *overrides["rich"].ContextWindow != ctxWindow {
t.Errorf("overrides[rich] = %+v", overrides["rich"])
}
if got := ProviderModelMetadataOverrides(nil); got != nil {
t.Errorf("nil input -> %v, want nil", got)
}
}
Loading