Skip to content
Open
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
9 changes: 9 additions & 0 deletions internal/catalog/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ func (ic *IndexedCatalog) ResolveShort(short string) (ResolvedModel, error) {
return ic.resolveFromMatches(short, matches)
}

for key, model := range ic.Models {
if strings.EqualFold(key, short) || strings.EqualFold(model.Name, short) || strings.EqualFold(modelNameFromKey(key), short) {
matches = append(matches, key)
}
}
if len(matches) > 0 {
return ic.resolveFromMatches(short, matches)
}

return ResolvedModel{}, fmt.Errorf("unknown short model id: %q", short)
}

Expand Down
21 changes: 21 additions & 0 deletions internal/catalog/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package catalog

import (
"sort"
"strings"
"testing"
)

Expand Down Expand Up @@ -239,6 +240,26 @@ func TestResolveShort_Name(t *testing.T) {
}
}

func TestResolveShort_CaseInsensitiveCanonicalizesUniqueModel(t *testing.T) {
ic := newFixtureCatalog()

got, err := ic.ResolveShort("DeepSeek-V4-Flash")
if err != nil {
t.Fatalf("ResolveShort unexpected error: %v", err)
}
if got.ModelID != "deepseek-v4-flash" || got.CanonicalName != "opencode-go/deepseek-v4-flash" {
t.Fatalf("ResolveShort = %+v, want canonical deepseek model", got)
}
}

func TestResolveShort_CaseInsensitiveStillRejectsAmbiguity(t *testing.T) {
ic := newFixtureCatalog()

if _, err := ic.ResolveShort("KIMI-K2.6"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("ResolveShort error = %v, want ambiguity", err)
}
}

func TestResolveShort_DisabledProvider(t *testing.T) {
ic := newFixtureCatalog()

Expand Down
25 changes: 25 additions & 0 deletions internal/config/model_registry.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package config

import "strings"

const DefaultContextMargin = 8192

// ModelMetadata describes a known model's capabilities (context window size,
Expand Down Expand Up @@ -38,13 +40,36 @@ var modelMetadata = map[string]ModelMetadata{
"qwen3.5-plus": {ContextWindow: 1000000, MaxOutputTokens: 8192, Vision: true, SupportsTools: true},
}

// CanonicalModelID returns the registered spelling for a known model ID.
// Unknown and ambiguously cased custom IDs are preserved unchanged.
func CanonicalModelID(modelID string) string {
if _, ok := modelMetadata[modelID]; ok {
return modelID
}
match := ""
for known := range modelMetadata {
if !strings.EqualFold(known, modelID) {
continue
}
if match != "" {
return modelID
}
match = known
}
if match != "" {
return match
}
return modelID
}

// ResolveModelConfig fills in default capability values (context window,
// max output tokens, vision, tool support) for a ModelConfig by consulting
// the built-in modelMetadata registry. If the model is unknown or a field
// is already set, the existing value is preserved. Call this before using
// a ModelConfig so capacity filtering and scenario routing see accurate
// per-model limits.
func ResolveModelConfig(model ModelConfig) ModelConfig {
model.ModelID = CanonicalModelID(model.ModelID)
if model.ModelRef == "" {
if meta, ok := modelMetadata[model.ModelID]; ok {
if model.ContextWindow == 0 {
Expand Down
20 changes: 20 additions & 0 deletions internal/config/model_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ func TestResolveModelConfig(t *testing.T) {
SupportsTools: boolPtr(true),
},
},
{
name: "known mixed-case model uses canonical ID and metadata",
input: ModelConfig{ModelID: "DeepSeek-V4-Pro"},
expected: ModelConfig{
ModelID: "deepseek-v4-pro",
ContextWindow: 1000000,
MaxOutputTokens: 8192,
ContextMargin: DefaultContextMargin,
SupportsTools: boolPtr(true),
},
},
{
name: "unknown custom model preserves case",
input: ModelConfig{ModelID: "Vendor-Custom-Pro"},
expected: ModelConfig{
ModelID: "Vendor-Custom-Pro",
ContextMargin: DefaultContextMargin,
SupportsTools: boolPtr(true),
},
},
{
name: "kimi-k3 gets hardcoded metadata (1M context, 131K output, vision)",
input: ModelConfig{
Expand Down
8 changes: 6 additions & 2 deletions internal/router/model_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s

// Look up the requested model in config to inherit its settings
primary, ok := cfg.Models[requestedModel]
canonicalRequestedModel := config.CanonicalModelID(requestedModel)
if !ok && canonicalRequestedModel != requestedModel {
primary, ok = cfg.Models[canonicalRequestedModel]
}
if !ok {
// Not in legacy config — try the catalog before falling back to the
// legacy unknown-model behavior. Provider-qualified references that
Expand All @@ -108,12 +112,12 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s
} else if providerQualified {
return RouteResult{}, false, fmt.Errorf("model reference %q uses unknown provider %q: %w", requestedModel, sel.Provider, ErrUnknownProvider)
} else {
primary = r.legacyUnknownModelConfig(cfg, requestedModel)
primary = r.legacyUnknownModelConfig(cfg, canonicalRequestedModel)
}
} else if providerQualified {
return RouteResult{}, false, fmt.Errorf("model reference %q uses unknown provider %q: %w", requestedModel, sel.Provider, ErrUnknownProvider)
} else {
primary = r.legacyUnknownModelConfig(cfg, requestedModel)
primary = r.legacyUnknownModelConfig(cfg, canonicalRequestedModel)
}
}
primary = config.ResolveModelConfig(primary)
Expand Down
13 changes: 13 additions & 0 deletions internal/router/model_router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,12 @@ func TestResolveRequestedModel(t *testing.T) {
Temperature: 0.3,
MaxTokens: 2048,
},
"deepseek-v4-pro": {
Provider: "opencode-go",
ModelID: "deepseek-v4-pro",
Temperature: 0.7,
MaxTokens: 8192,
},
},
Fallbacks: map[string][]config.ModelConfig{
"default": {{Provider: "opencode-go", ModelID: "qwen3.5-plus"}},
Expand Down Expand Up @@ -538,6 +544,13 @@ func TestResolveRequestedModel(t *testing.T) {
wantModelID: "deepseek-v4-flash",
wantModelRef: "deepseek-v4-flash",
},
{
name: "mixed-case known model resolves to configured canonical ID",
requestedModel: "DeepSeek-V4-Pro",
wantProvider: "opencode-go",
wantModelID: "deepseek-v4-pro",
wantModelRef: "",
},
{
name: "config model takes precedence over catalog",
requestedModel: "custom-model",
Expand Down
7 changes: 7 additions & 0 deletions internal/transformer/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,13 @@ func TestTransformRequestThinkingDecisionMatrix(t *testing.T) {
model: config.ModelConfig{ModelID: "qwen3.6-plus"},
wantThink: "",
},
{
name: "kimi ignores request and history thinking without explicit capability",
messages: thinkingHistory,
thinking: json.RawMessage(`{"type":"enabled","budget_tokens":4096}`),
model: config.ModelConfig{ModelID: "kimi-k2.6"},
wantThink: "",
},
{
name: "request disabled overrides explicit model thinking",
messages: userOnly,
Expand Down
Loading