Skip to content
Closed
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
12 changes: 12 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ type Config struct {
// VirtualModels declares redirects, load balancers, and access policies as
// infrastructure-as-code. They override admin-store rows of the same source.
VirtualModels []VirtualModelConfig `yaml:"virtual_models"`

// ModelNormalizer declares canonical model aliases rewritten by the gateway
// before provider dispatch. Rules map an alias to a provider/model target
// and optionally pin a thinking policy. The MODEL_NORMALIZER env var
// (JSON array) merges over this list and wins per alias.
ModelNormalizer []ModelNormalizerRule `yaml:"model_normalizer"`
}

// LoadResult is returned by Load and bundles the application config with the raw
Expand Down Expand Up @@ -231,6 +237,12 @@ func Load() (*LoadResult, error) {
if err := applyVirtualModelsEnv(cfg, strict); err != nil {
return nil, err
}
if err := applyModelNormalizerEnv(cfg, strict); err != nil {
return nil, err
}
if err := validateModelNormalizerRules(cfg.ModelNormalizer); err != nil {
return nil, err
}
if err := applyTaggingEnv(cfg); err != nil {
return nil, err
}
Expand Down
72 changes: 72 additions & 0 deletions config/modelnormalizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package config

import (
"fmt"
"os"
"strings"
)

// ModelNormalizerRule declares one model normalization rule in config.yaml or
// the MODEL_NORMALIZER env var. It maps a canonical client-facing alias to a
// provider/model target and optionally pins a thinking policy, mirroring the
// behavior of the internal/modelnormalizer package. Rules are read-only at
// runtime: they live alongside virtual_models as infrastructure-as-code.
type ModelNormalizerRule struct {
// Alias is the client-facing model ID that triggers this rule.
Alias string `yaml:"alias" json:"alias"`

// Target is the provider/model selector the request is rewritten to, e.g.
// "kimicode/kimi-for-coding".
Target string `yaml:"target" json:"target"`

// Thinking pins the thinking policy: "enabled", "disabled", or "passthrough".
// Empty is treated as passthrough (no field is injected).
Thinking string `yaml:"thinking,omitempty" json:"thinking,omitempty"`

// ContextWindow is the advertised context window for /v1/models metadata.
ContextWindow *int `yaml:"context_window,omitempty" json:"context_window,omitempty"`

// Modes declares the model's kinds for registry metadata, e.g. ["chat"].
Modes []string `yaml:"modes,omitempty" json:"modes,omitempty"`
}

const envModelNormalizer = "MODEL_NORMALIZER"

// validateModelNormalizerRules checks that every rule has a non-empty alias
// and target and a known thinking policy. A rule with an invalid policy would
// silently rewrite requests to an undefined state, so fail fast at load time.
func validateModelNormalizerRules(rules []ModelNormalizerRule) error {
for i, r := range rules {
if strings.TrimSpace(r.Alias) == "" {
return fmt.Errorf("model_normalizer[%d]: alias is required", i)
}
if strings.TrimSpace(r.Target) == "" {
return fmt.Errorf("model_normalizer[%d] (%s): target is required", i, r.Alias)
}
switch strings.TrimSpace(r.Thinking) {
case "", "passthrough", "enabled", "disabled":
default:
return fmt.Errorf("model_normalizer[%d] (%s): thinking must be one of: enabled, disabled, passthrough; got %q", i, r.Alias, r.Thinking)
}
}
return nil
}

// applyModelNormalizerEnv parses the MODEL_NORMALIZER env var — a JSON array
// of model normalization rules — and merges it over the YAML-declared list.
// Env entries override YAML entries with the same alias (case-insensitive),
// consistent with the rest of the config pipeline where env always wins.
func applyModelNormalizerEnv(cfg *Config, strict bool) error {
raw := strings.TrimSpace(os.Getenv(envModelNormalizer))
if raw == "" {
return nil
}
var fromEnv []ModelNormalizerRule
if err := decodeIaCJSON(envModelNormalizer, raw, &fromEnv, strict); err != nil {
return fmt.Errorf("invalid %s: %w", envModelNormalizer, err)
}
cfg.ModelNormalizer = mergeByKey(cfg.ModelNormalizer, fromEnv, func(rule ModelNormalizerRule) string {
return canonicalTextKey(rule.Alias)
})
return nil
}
156 changes: 156 additions & 0 deletions config/modelnormalizer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package config

import (
"fmt"
"strings"
"testing"
)

func TestApplyModelNormalizerEnv_ParsesAndMerges(t *testing.T) {
cfg := &Config{ModelNormalizer: []ModelNormalizerRule{
{Alias: "kimi-k2.6", Target: "kimicode/kimi-for-coding", Thinking: "disabled"},
{Alias: "kimi-k2.7-code", Target: "kimicode/kimi-for-coding"},
}}
t.Setenv(envModelNormalizer, `[
{"alias":"kimi-k2.6","target":"kimicode/kimi-for-coding","thinking":"passthrough"},
{"alias":"kimi-k3","target":"kimicode/k3","context_window":1048576,"modes":["chat"]}
]`)

if err := applyModelNormalizerEnv(cfg, true); err != nil {
t.Fatalf("applyModelNormalizerEnv() error = %v", err)
}
if len(cfg.ModelNormalizer) != 3 {
t.Fatalf("merged len = %d, want 3", len(cfg.ModelNormalizer))
}
// "kimi-k2.6" is overridden in place (env wins) and keeps its position.
k26 := cfg.ModelNormalizer[0]
if k26.Alias != "kimi-k2.6" || k26.Thinking != "passthrough" {
t.Fatalf("env did not override kimi-k2.6: %#v", k26)
}
// "kimi-k2.7-code" is untouched; "kimi-k3" is appended.
if cfg.ModelNormalizer[1].Alias != "kimi-k2.7-code" || cfg.ModelNormalizer[2].Alias != "kimi-k3" {
t.Fatalf("merge order wrong: %#v", cfg.ModelNormalizer)
}
// Verify the new entry carries metadata.
k3 := cfg.ModelNormalizer[2]
if k3.ContextWindow == nil || *k3.ContextWindow != 1048576 {
t.Fatalf("kimi-k3 context_window = %v, want 1048576", k3.ContextWindow)
}
if len(k3.Modes) != 1 || k3.Modes[0] != "chat" {
t.Fatalf("kimi-k3 modes = %v, want [chat]", k3.Modes)
}
}

func TestApplyModelNormalizerEnv_Invalid(t *testing.T) {
cfg := &Config{}
t.Setenv(envModelNormalizer, `{not valid json`)
if err := applyModelNormalizerEnv(cfg, true); err == nil {
t.Fatalf("applyModelNormalizerEnv() error = nil, want parse error")
}
}

// The env layer overrides YAML entry by entry, so a typo must fail loudly rather
// than let a malformed env entry silently win over a correct YAML one.
func TestApplyModelNormalizerEnv_RejectsUnknownField(t *testing.T) {
cfg := &Config{}
t.Setenv(envModelNormalizer, `[{"alias":"kimi-k2.6","targets":"kimicode/kimi-for-coding"}]`)

err := applyModelNormalizerEnv(cfg, true)
if err == nil {
t.Fatal("applyModelNormalizerEnv() error = nil, want unknown-field error")
}
if !strings.Contains(err.Error(), "targets") {
t.Fatalf("applyModelNormalizerEnv() error = %q, want it to name the unknown field", err)
}
}

// json.Decoder stops after the first value and leaves the rest unread, so trailing
// data must be rejected explicitly — silently applying half an env var is the failure
// this path exists to prevent. Structural, therefore fatal in both modes.
func TestApplyModelNormalizerEnv_RejectsTrailingData(t *testing.T) {
trailing := map[string]string{
"garbage suffix": `[{"alias":"a","target":"b"}] and then some junk`,
"second JSON value": `[{"alias":"a","target":"b"}] {"alias":"c","target":"d"}`,
"second JSON on a line": "[{\"alias\":\"a\",\"target\":\"b\"}]\n{\"alias\":\"c\",\"target\":\"d\"}",
}
for name, raw := range trailing {
for _, strict := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/strict=%v", name, strict), func(t *testing.T) {
cfg := &Config{}
t.Setenv(envModelNormalizer, raw)

err := applyModelNormalizerEnv(cfg, strict)
if err == nil {
t.Fatal("applyModelNormalizerEnv() error = nil, want trailing-data error")
}
if !strings.Contains(err.Error(), "unexpected data after the JSON value") {
t.Fatalf("applyModelNormalizerEnv() error = %q, want a trailing-data error", err)
}
})
}
}
}

func TestValidateModelNormalizerRules(t *testing.T) {
tests := []struct {
name string
rules []ModelNormalizerRule
wantErr string
}{
{name: "nil is valid"},
{name: "empty slice is valid", rules: []ModelNormalizerRule{}},
{name: "valid rule", rules: []ModelNormalizerRule{{Alias: "a", Target: "b"}}},
{name: "valid with thinking", rules: []ModelNormalizerRule{{Alias: "a", Target: "b", Thinking: "disabled"}}},
{name: "valid with passthrough", rules: []ModelNormalizerRule{{Alias: "a", Target: "b", Thinking: "passthrough"}}},
{name: "missing alias", rules: []ModelNormalizerRule{{Target: "b"}}, wantErr: "alias is required"},
{name: "missing target", rules: []ModelNormalizerRule{{Alias: "a"}}, wantErr: "target is required"},
{name: "invalid thinking", rules: []ModelNormalizerRule{{Alias: "a", Target: "b", Thinking: "bogus"}}, wantErr: "thinking must be one of"},
{name: "whitespace alias", rules: []ModelNormalizerRule{{Alias: " ", Target: "b"}}, wantErr: "alias is required"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateModelNormalizerRules(tt.rules)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validateModelNormalizerRules() error = %v, want nil", err)
}
return
}
if err == nil {
t.Fatalf("validateModelNormalizerRules() error = nil, want %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validateModelNormalizerRules() error = %q, want to contain %q", err, tt.wantErr)
}
})
}
}

func TestApplyModelNormalizerEnv_NoOpWhenUnset(t *testing.T) {
cfg := &Config{ModelNormalizer: []ModelNormalizerRule{
{Alias: "kimi-k2.6", Target: "kimicode/kimi-for-coding"},
}}
if err := applyModelNormalizerEnv(cfg, true); err != nil {
t.Fatalf("applyModelNormalizerEnv() error = %v", err)
}
if len(cfg.ModelNormalizer) != 1 {
t.Fatalf("len = %d, want 1 (unchanged)", len(cfg.ModelNormalizer))
}
}

func TestApplyModelNormalizerEnv_CaseInsensitiveAliasKey(t *testing.T) {
cfg := &Config{ModelNormalizer: []ModelNormalizerRule{
{Alias: "Kimi-K2.6", Target: "kimicode/kimi-for-coding", Thinking: "disabled"},
}}
t.Setenv(envModelNormalizer, `[{"alias":"kimi-k2.6","target":"kimicode/kimi-for-coding","thinking":"passthrough"}]`)

if err := applyModelNormalizerEnv(cfg, true); err != nil {
t.Fatalf("applyModelNormalizerEnv() error = %v", err)
}
if len(cfg.ModelNormalizer) != 1 {
t.Fatalf("len = %d, want 1 (case-insensitive alias key)", len(cfg.ModelNormalizer))
}
if cfg.ModelNormalizer[0].Thinking != "passthrough" {
t.Fatalf("thinking = %q, want passthrough", cfg.ModelNormalizer[0].Thinking)
}
}
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/enterpilot/gomodel/internal/live"
"github.com/enterpilot/gomodel/internal/llmclient"
"github.com/enterpilot/gomodel/internal/mcpgateway"
"github.com/enterpilot/gomodel/internal/modelnormalizer"
"github.com/enterpilot/gomodel/internal/pricingoverrides"
"github.com/enterpilot/gomodel/internal/providers"
"github.com/enterpilot/gomodel/internal/providers/health"
Expand Down Expand Up @@ -746,6 +747,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
TranslatedRequestPatcher: translatedRequestPatcher,
BatchRequestPreparer: batchRequestPreparer,
ExposedModelLister: vm,
ModelNormalizer: modelnormalizer.BuildFromConfig(appCfg.ModelNormalizer),
KeepOnlyAliasesAtModelsEndpoint: appCfg.Models.KeepOnlyAliasesAtModelsEndpoint,
PassthroughSemanticEnrichers: cfg.Factory.PassthroughSemanticEnrichers(),
BatchStore: batchResult.Store,
Expand Down
53 changes: 53 additions & 0 deletions internal/modelnormalizer/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package modelnormalizer

import (
"github.com/enterpilot/gomodel/config"

"github.com/enterpilot/gomodel/internal/core"
)

// BuildFromConfig constructs a Normalizer from a config.Config slice. Empty or
// blank rules are silently dropped. The result is nil when no valid rules are
// declared, so callers can pass it directly to server/gateway hooks without
// nil-checking downstream.
func BuildFromConfig(rules []config.ModelNormalizerRule) *Normalizer {
if len(rules) == 0 {
return nil
}
converted := make([]Rule, 0, len(rules))
for _, r := range rules {
converted = append(converted, Rule{
Alias: r.Alias,
Target: r.Target,
Thinking: ThinkingPolicy(r.Thinking),
ContextWindow: r.ContextWindow,
Modes: r.Modes,
})
}
return New(converted)
}

// ChainedExposedModelLister wraps two ExposedModels functions so the
// secondary's output is appended to the primary. The server layer adapts this
// onto its ExposedModelLister interface. Used when a normalizer is configured
// alongside another lister (e.g. virtual models) so canonical aliases still
// appear in /v1/models.
type ChainedExposedModelLister struct {
Primary func() []core.Model
Secondary func() []core.Model
}

// ExposedModels concatenates both sources. A nil primary collapses to the
// secondary; a nil secondary collapses to the primary; both nil returns nil.
func (c ChainedExposedModelLister) ExposedModels() []core.Model {
if c.Primary == nil {
if c.Secondary == nil {
return nil
}
return c.Secondary()
}
if c.Secondary == nil {
return c.Primary()
}
return append(c.Primary(), c.Secondary()...)
}
Loading