feat(runtime): enhance Devin model aggregation and UID normalization

- Implement `splitDevinUID` for consistent UID splitting and effort extraction.
- Update model aggregation to handle thinking levels and merge variants.
- Refine display name cleaning logic to remove redundant suffixes.
- Adjust Devin catalog integration to exclude exposed thinking variants.
- Normalize and sort thinking levels with an extended rank scheme.

Closes: #6141
This commit is contained in:
Luis Pater
2026-09-27 02:03:55 +08:00
parent be4b28f165
commit fc2c98d1ca
7 changed files with 850 additions and 1571 deletions

View File

@@ -32,6 +32,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
@@ -407,6 +408,90 @@ func formatRawModels(raw []rawDevinModel) []devinModelJSON {
return res
}
var devinCompoundSuffixes = []struct {
suffix string
effort string
readd string
}{
{suffix: "-low-fast", effort: "low"},
{suffix: "-medium-fast", effort: "medium"},
{suffix: "-high-fast", effort: "high"},
{suffix: "-xhigh-fast", effort: "xhigh"},
{suffix: "-max-fast", effort: "max"},
{suffix: "-none-fast", effort: "none"},
{suffix: "-low-priority", effort: "low"},
{suffix: "-medium-priority", effort: "medium"},
{suffix: "-high-priority", effort: "high"},
{suffix: "-xhigh-priority", effort: "xhigh"},
{suffix: "-max-priority", effort: "max"},
{suffix: "-none-priority", effort: "none"},
{suffix: "-thinking-1m", effort: "", readd: "-1m"},
{suffix: "-thinking", effort: ""},
{suffix: "-max-1m", effort: "max", readd: "-1m"},
{suffix: "-none-1m", effort: "none", readd: "-1m"},
}
var devinSimpleEffortSuffixes = []struct {
suffix string
effort string
}{
{suffix: "-none", effort: "none"},
{suffix: "-minimal", effort: "minimal"},
{suffix: "-low", effort: "low"},
{suffix: "-medium", effort: "medium"},
{suffix: "-high", effort: "high"},
{suffix: "-xhigh", effort: "xhigh"},
{suffix: "-max", effort: "max"},
}
func splitDevinUID(uid string) (string, string) {
if uid == "swe-1-6-slow" {
return uid, ""
}
if uid == "swe-1-6-fast" {
return "swe-1-6", ""
}
upper := strings.ToUpper(uid)
for _, s := range []struct {
suffix string
effort string
}{
{"_NONE", "none"},
{"_MINIMAL", "minimal"},
{"_LOW", "low"},
{"_MEDIUM", "medium"},
{"_HIGH", "high"},
{"_XHIGH", "xhigh"},
{"_MAX", "max"},
{"_THINKING", "high"},
} {
if strings.HasSuffix(upper, s.suffix) {
base := uid[:len(uid)-len(s.suffix)]
return base, s.effort
}
}
for _, s := range devinCompoundSuffixes {
if strings.HasSuffix(uid, s.suffix) {
base := uid[:len(uid)-len(s.suffix)]
if s.readd != "" {
base += s.readd
}
return base, s.effort
}
}
for _, s := range devinSimpleEffortSuffixes {
if strings.HasSuffix(uid, s.suffix) {
base := uid[:len(uid)-len(s.suffix)]
return base, s.effort
}
}
return uid, ""
}
func aggregateModels(raw []rawDevinModel) []devinModelJSON {
type aggEntry struct {
baseID string
@@ -417,43 +502,51 @@ func aggregateModels(raw []rawDevinModel) []devinModelJSON {
levels map[string]struct{}
}
knownSuffixes := []string{"-minimal", "-low", "-medium", "-high", "-xhigh", "-max", "-none", "-priority"}
grouped := make(map[string]*aggEntry)
var order []string
for _, r := range raw {
base := r.UID
level := ""
for _, s := range knownSuffixes {
if strings.HasSuffix(base, s) {
level = strings.TrimPrefix(s, "-")
base = strings.TrimSuffix(base, s)
break
}
base, level := splitDevinUID(r.UID)
if base == "" {
base = r.UID
}
isBase := (base == r.UID)
entry, exists := grouped[base]
if !exists {
initialLevels := make(map[string]struct{})
if mInfo := registry.LookupDevinModel(base); mInfo != nil && mInfo.Thinking != nil {
for _, l := range mInfo.Thinking.Levels {
if l != "" && l != "priority" {
initialLevels[l] = struct{}{}
}
}
}
entry = &aggEntry{
baseID: base,
displayName: cleanDisplayName(r.Label),
vendorID: r.VendorID,
contextLength: r.ContextLength,
multimodal: r.Multimodal,
levels: make(map[string]struct{}),
levels: initialLevels,
}
grouped[base] = entry
order = append(order, base)
}
if isBase {
entry.displayName = cleanDisplayName(r.Label)
if r.VendorID != 0 {
entry.vendorID = r.VendorID
}
}
if r.Multimodal {
entry.multimodal = true
}
if r.ContextLength > entry.contextLength {
entry.contextLength = r.ContextLength
}
if level != "" {
if level != "" && level != "priority" {
entry.levels[level] = struct{}{}
}
}
@@ -495,23 +588,45 @@ func aggregateModels(raw []rawDevinModel) []devinModelJSON {
return res
}
var devinDisplayNameSuffixes = []string{
" Low Fast", " Medium Fast", " High Fast", " XHigh Fast", " Max Fast",
" Low Thinking Fast", " Medium Thinking Fast", " High Thinking Fast",
" XHigh Thinking Fast", " Max Thinking Fast", " No Thinking Fast",
" Low Thinking", " Medium Thinking", " High Thinking", " XHigh Thinking",
" Max Thinking", " No Thinking",
" Low", " Medium", " High", " XHigh", " Max", " None", " Minimal",
" Thinking", " Fast",
}
func cleanDisplayName(label string) string {
clean := label
for _, s := range []string{" Low", " Medium", " High", " XHigh", " Max", " Minimal", " None", " Priority"} {
clean = strings.TrimSuffix(clean, s)
trimmed := strings.TrimSpace(label)
for {
changed := false
for _, s := range devinDisplayNameSuffixes {
if strings.HasSuffix(strings.ToLower(trimmed), strings.ToLower(s)) {
trimmed = strings.TrimSpace(trimmed[:len(trimmed)-len(s)])
changed = true
break
}
}
if !changed {
break
}
}
return clean
return trimmed
}
func sortLevels(levels []string) {
rank := map[string]int{
"none": 0,
"minimal": 1,
"low": 2,
"medium": 3,
"high": 4,
"xhigh": 5,
"max": 6,
"none": 0,
"minimal": 1,
"low": 2,
"medium": 3,
"high": 4,
"xhigh": 5,
"max": 6,
"fast": 7,
"priority": 8,
}
sort.Slice(levels, func(i, j int) bool {
rI, okI := rank[levels[i]]
@@ -522,6 +637,9 @@ func sortLevels(levels []string) {
if !okJ {
rJ = 99
}
return rI < rJ
if rI != rJ {
return rI < rJ
}
return levels[i] < levels[j]
})
}

View File

@@ -0,0 +1,136 @@
package main
import (
"reflect"
"testing"
)
func TestSplitDevinUID(t *testing.T) {
tests := []struct {
uid string
wantBase string
wantEffort string
}{
{"claude-opus-5-low-fast", "claude-opus-5", "low"},
{"claude-opus-5-max-fast", "claude-opus-5", "max"},
{"gpt-6-astra-low-priority", "gpt-6-astra", "low"},
{"gpt-6-astra-high", "gpt-6-astra", "high"},
{"MODEL_GPT_5_2_LOW", "MODEL_GPT_5_2", "low"},
{"MODEL_GPT_5_2_HIGH", "MODEL_GPT_5_2", "high"},
{"MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL", "MODEL_GOOGLE_GEMINI_3_0_FLASH", "minimal"},
{"claude-opus-4-6-thinking", "claude-opus-4-6", ""},
{"claude-opus-4-6-thinking-1m", "claude-opus-4-6-1m", ""},
{"glm-5-2-max-1m", "glm-5-2-1m", "max"},
{"swe-1-6-slow", "swe-1-6-slow", ""},
{"swe-1-6-fast", "swe-1-6", ""},
{"swe-2", "swe-2", ""},
}
for _, tt := range tests {
t.Run(tt.uid, func(t *testing.T) {
base, effort := splitDevinUID(tt.uid)
if base != tt.wantBase {
t.Errorf("splitDevinUID(%q) base = %q, want %q", tt.uid, base, tt.wantBase)
}
if effort != tt.wantEffort {
t.Errorf("splitDevinUID(%q) effort = %q, want %q", tt.uid, effort, tt.wantEffort)
}
})
}
}
func TestAggregateModels_MergesThinkingVariants_Issue6141(t *testing.T) {
raw := []rawDevinModel{
{
UID: "test-opus-model",
Label: "Test Opus Model",
ContextLength: 1000000,
Multimodal: true,
VendorID: 3,
},
{
UID: "test-opus-model-low-fast",
Label: "Test Opus Model Low Fast",
ContextLength: 1000000,
Multimodal: true,
VendorID: 3,
},
{
UID: "test-opus-model-high-fast",
Label: "Test Opus Model High Fast",
ContextLength: 1000000,
Multimodal: true,
VendorID: 3,
},
{
UID: "test-astra-model-low-priority",
Label: "Test Astra Model Low Thinking Fast",
ContextLength: 1000000,
Multimodal: true,
VendorID: 2,
},
{
UID: "test-astra-model-high-priority",
Label: "Test Astra Model High Thinking Fast",
ContextLength: 1000000,
Multimodal: true,
VendorID: 2,
},
}
aggregated := aggregateModels(raw)
if len(aggregated) != 2 {
t.Fatalf("expected 2 aggregated models, got %d: %+v", len(aggregated), aggregated)
}
// Verify test-opus-model
opus := aggregated[0]
if opus.ID != "test-opus-model" {
t.Errorf("expected ID test-opus-model, got %q", opus.ID)
}
if opus.DisplayName != "Test Opus Model" {
t.Errorf("expected DisplayName 'Test Opus Model', got %q", opus.DisplayName)
}
if opus.Thinking == nil {
t.Fatalf("expected Thinking to be populated for test-opus-model")
}
wantOpusLevels := []string{"low", "high"}
if !reflect.DeepEqual(opus.Thinking.Levels, wantOpusLevels) {
t.Errorf("test-opus-model levels = %v, want %v", opus.Thinking.Levels, wantOpusLevels)
}
// Verify test-astra-model
astra := aggregated[1]
if astra.ID != "test-astra-model" {
t.Errorf("expected ID test-astra-model, got %q", astra.ID)
}
if astra.DisplayName != "Test Astra Model" {
t.Errorf("expected DisplayName 'Test Astra Model', got %q", astra.DisplayName)
}
if astra.Thinking == nil {
t.Fatalf("expected Thinking to be populated for test-astra-model")
}
wantAstraLevels := []string{"low", "high"}
if !reflect.DeepEqual(astra.Thinking.Levels, wantAstraLevels) {
t.Errorf("test-astra-model levels = %v, want %v", astra.Thinking.Levels, wantAstraLevels)
}
}
func TestAggregateModels_PreservesCatalogThinkingLevels(t *testing.T) {
raw := []rawDevinModel{
{
UID: "claude-fable-5-1",
Label: "Claude Fable 5.1",
ContextLength: 1000000,
Multimodal: true,
VendorID: 3,
},
}
aggregated := aggregateModels(raw)
if len(aggregated) != 1 {
t.Fatalf("expected 1 model, got %d", len(aggregated))
}
if aggregated[0].Thinking == nil || len(aggregated[0].Thinking.Levels) == 0 {
t.Fatalf("expected thinking levels seeded from catalog for claude-fable-5-1, got nil")
}
}

View File

@@ -5,6 +5,8 @@ import (
_ "embed"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"sync"
@@ -108,6 +110,17 @@ func LookupDevinModel(modelID string) *ModelInfo {
return cloneModelInfo(m)
}
}
// Fallback: If clean is a thinking variant (e.g. claude-opus-5-low-fast or gpt-6-astra-high),
// look up its base model (e.g. claude-opus-5 or gpt-6-astra).
if base, _ := splitDevinModelID(clean); base != clean && base != "" {
for _, m := range models {
mClean := strings.ToLower(strings.TrimPrefix(m.ID, "devin/"))
if mClean == base {
return cloneModelInfo(m)
}
}
}
return nil
}
@@ -181,30 +194,245 @@ func ValidateDevinModelsJSON(data []byte) ([]*ModelInfo, error) {
return nil, fmt.Errorf("invalid Devin models JSON: expected non-empty 'devin'/'models' array or model list")
}
func sanitizeAndValidateDevinModels(models []*ModelInfo) ([]*ModelInfo, error) {
seen := make(map[string]struct{}, len(models))
out := make([]*ModelInfo, 0, len(models))
var devinCompoundSuffixes = []struct {
suffix string
effort string
readd string
}{
{suffix: "-low-fast", effort: "low"},
{suffix: "-medium-fast", effort: "medium"},
{suffix: "-high-fast", effort: "high"},
{suffix: "-xhigh-fast", effort: "xhigh"},
{suffix: "-max-fast", effort: "max"},
{suffix: "-none-fast", effort: "none"},
{suffix: "-low-priority", effort: "low"},
{suffix: "-medium-priority", effort: "medium"},
{suffix: "-high-priority", effort: "high"},
{suffix: "-xhigh-priority", effort: "xhigh"},
{suffix: "-max-priority", effort: "max"},
{suffix: "-none-priority", effort: "none"},
{suffix: "-thinking-1m", effort: "", readd: "-1m"},
{suffix: "-thinking", effort: ""},
{suffix: "-max-1m", effort: "max", readd: "-1m"},
{suffix: "-none-1m", effort: "none", readd: "-1m"},
}
for i, m := range models {
var devinSimpleEffortSuffixes = []struct {
suffix string
effort string
}{
{suffix: "-none", effort: "none"},
{suffix: "-minimal", effort: "minimal"},
{suffix: "-low", effort: "low"},
{suffix: "-medium", effort: "medium"},
{suffix: "-high", effort: "high"},
{suffix: "-xhigh", effort: "xhigh"},
{suffix: "-max", effort: "max"},
}
var devinDisplayNameSuffixes = []string{
" Low Fast", " Medium Fast", " High Fast", " XHigh Fast", " Max Fast",
" Low Thinking Fast", " Medium Thinking Fast", " High Thinking Fast",
" XHigh Thinking Fast", " Max Thinking Fast", " No Thinking Fast",
" Low Thinking", " Medium Thinking", " High Thinking", " XHigh Thinking",
" Max Thinking", " No Thinking",
" Low", " Medium", " High", " XHigh", " Max", " None", " Minimal",
" Thinking", " Fast",
}
var devinLevelOrder = map[string]int{
"none": 0,
"minimal": 1,
"low": 2,
"medium": 3,
"high": 4,
"xhigh": 5,
"max": 6,
"fast": 7,
"priority": 8,
}
func splitDevinModelID(cleanID string) (string, string) {
if cleanID == "swe-1-6-slow" {
return cleanID, ""
}
if cleanID == "swe-1-6-fast" {
return "swe-1-6", ""
}
upper := strings.ToUpper(cleanID)
for _, s := range []struct {
suffix string
effort string
}{
{"_NONE", "none"},
{"_MINIMAL", "minimal"},
{"_LOW", "low"},
{"_MEDIUM", "medium"},
{"_HIGH", "high"},
{"_XHIGH", "xhigh"},
{"_MAX", "max"},
{"_THINKING", "high"},
} {
if strings.HasSuffix(upper, s.suffix) {
base := cleanID[:len(cleanID)-len(s.suffix)]
return base, s.effort
}
}
for _, s := range devinCompoundSuffixes {
if strings.HasSuffix(cleanID, s.suffix) {
base := cleanID[:len(cleanID)-len(s.suffix)]
if s.readd != "" {
base += s.readd
}
return base, s.effort
}
}
for _, s := range devinSimpleEffortSuffixes {
if strings.HasSuffix(cleanID, s.suffix) {
base := cleanID[:len(cleanID)-len(s.suffix)]
return base, s.effort
}
}
return cleanID, ""
}
func cleanDevinDisplayName(name string) string {
trimmed := strings.TrimSpace(name)
for {
changed := false
for _, s := range devinDisplayNameSuffixes {
if strings.HasSuffix(strings.ToLower(trimmed), strings.ToLower(s)) {
trimmed = strings.TrimSpace(trimmed[:len(trimmed)-len(s)])
changed = true
break
}
}
if !changed {
break
}
}
return trimmed
}
func aggregateDevinModels(models []*ModelInfo) []*ModelInfo {
type aggEntry struct {
model *ModelInfo
levels map[string]struct{}
}
order := make([]string, 0, len(models))
aggregated := make(map[string]*aggEntry, len(models))
for _, m := range models {
if m == nil {
return nil, fmt.Errorf("model at index %d is null", i)
continue
}
id := strings.TrimSpace(m.ID)
if id == "" {
return nil, fmt.Errorf("model at index %d has empty id", i)
cleanID := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(m.ID), "devin/"))
baseID, effort := splitDevinModelID(cleanID)
if baseID == "" {
baseID = cleanID
}
// Automatically namespace model IDs under devin/ if not already prefixed
if !strings.HasPrefix(strings.ToLower(id), "devin/") {
id = "devin/" + id
}
id = strings.ToLower(id)
m.ID = id
if _, exists := seen[id]; exists {
return nil, fmt.Errorf("duplicate model id: %q", id)
}
seen[id] = struct{}{}
namespacedBase := "devin/" + baseID
isBase := (baseID == cleanID)
// Ensure proper default fields
entry, exists := aggregated[namespacedBase]
if !exists {
clone := *m
clone.ID = namespacedBase
clone.DisplayName = cleanDevinDisplayName(m.DisplayName)
if clone.DisplayName == "" {
clone.DisplayName = m.DisplayName
}
entry = &aggEntry{
model: &clone,
levels: make(map[string]struct{}),
}
aggregated[namespacedBase] = entry
order = append(order, namespacedBase)
}
if isBase {
if m.DisplayName != "" {
entry.model.DisplayName = cleanDevinDisplayName(m.DisplayName)
}
if m.OwnedBy != "" {
entry.model.OwnedBy = m.OwnedBy
}
}
if m.ContextLength > entry.model.ContextLength {
entry.model.ContextLength = m.ContextLength
}
if m.MaxCompletionTokens > entry.model.MaxCompletionTokens {
entry.model.MaxCompletionTokens = m.MaxCompletionTokens
}
if m.InputTokenLimit > entry.model.InputTokenLimit {
entry.model.InputTokenLimit = m.InputTokenLimit
}
if m.OutputTokenLimit > entry.model.OutputTokenLimit {
entry.model.OutputTokenLimit = m.OutputTokenLimit
}
for _, mod := range m.SupportedInputModalities {
if !slices.Contains(entry.model.SupportedInputModalities, mod) {
entry.model.SupportedInputModalities = append(entry.model.SupportedInputModalities, mod)
}
}
for _, mod := range m.SupportedOutputModalities {
if !slices.Contains(entry.model.SupportedOutputModalities, mod) {
entry.model.SupportedOutputModalities = append(entry.model.SupportedOutputModalities, mod)
}
}
for _, gen := range m.SupportedGenerationMethods {
if !slices.Contains(entry.model.SupportedGenerationMethods, gen) {
entry.model.SupportedGenerationMethods = append(entry.model.SupportedGenerationMethods, gen)
}
}
if m.Thinking != nil && len(m.Thinking.Levels) > 0 {
for _, l := range m.Thinking.Levels {
if l != "" && l != "priority" {
entry.levels[l] = struct{}{}
}
}
}
if effort != "" && effort != "priority" {
entry.levels[effort] = struct{}{}
}
}
out := make([]*ModelInfo, 0, len(order))
for _, id := range order {
entry := aggregated[id]
m := entry.model
if len(entry.levels) > 0 {
lvls := make([]string, 0, len(entry.levels))
for l := range entry.levels {
lvls = append(lvls, l)
}
sort.Slice(lvls, func(i, j int) bool {
rI, okI := devinLevelOrder[lvls[i]]
if !okI {
rI = 99
}
rJ, okJ := devinLevelOrder[lvls[j]]
if !okJ {
rJ = 99
}
if rI != rJ {
return rI < rJ
}
return lvls[i] < lvls[j]
})
m.Thinking = &ThinkingSupport{
Levels: lvls,
}
}
// Ensure defaults
if m.Type == "" {
m.Type = "devin"
}
@@ -226,8 +454,35 @@ func sanitizeAndValidateDevinModels(models []*ModelInfo) ([]*ModelInfo, error) {
if len(m.SupportedGenerationMethods) == 0 {
m.SupportedGenerationMethods = []string{"generateContent", "countTokens"}
}
out = append(out, m)
}
return out, nil
return out
}
func sanitizeAndValidateDevinModels(models []*ModelInfo) ([]*ModelInfo, error) {
seenExact := make(map[string]struct{}, len(models))
for i, m := range models {
if m == nil {
return nil, fmt.Errorf("model at index %d is null", i)
}
id := strings.TrimSpace(m.ID)
if id == "" {
return nil, fmt.Errorf("model at index %d has empty id", i)
}
// Automatically namespace model IDs under devin/ if not already prefixed
if !strings.HasPrefix(strings.ToLower(id), "devin/") {
id = "devin/" + id
}
id = strings.ToLower(id)
m.ID = id
if _, exists := seenExact[id]; exists {
return nil, fmt.Errorf("duplicate model id: %q", id)
}
seenExact[id] = struct{}{}
}
return aggregateDevinModels(models), nil
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
@@ -351,3 +353,66 @@ func TestWithDevinBuiltins(t *testing.T) {
t.Errorf("expected DisplayName 'SWE-1.6 Slow', got %q", replaced[0].DisplayName)
}
}
func TestEmbeddedDevinModels_NoThinkingSuffixes_Issue6141(t *testing.T) {
models := GetDevinModels()
disallowedSuffixes := []string{
"-low-fast", "-medium-fast", "-high-fast", "-xhigh-fast", "-max-fast",
"-none-fast", "-low", "-medium", "-high", "-xhigh", "-max", "-none",
"-thinking-1m", "-thinking", "_none", "_minimal", "_low", "_medium",
"_high", "_xhigh", "_max", "_thinking",
}
var exposedThinkingVariants []string
for _, m := range models {
cleanID := strings.TrimPrefix(m.ID, "devin/")
for _, s := range disallowedSuffixes {
if strings.HasSuffix(cleanID, s) {
exposedThinkingVariants = append(exposedThinkingVariants, m.ID)
break
}
}
}
if len(exposedThinkingVariants) > 0 {
t.Fatalf("Devin catalog exposes thinking-parameter model variants (count=%d): %v", len(exposedThinkingVariants), exposedThinkingVariants)
}
}
func TestValidateDevinModelsJSON_AggregatesThinkingVariants_Issue6141(t *testing.T) {
payload := []byte(`{
"devin": [
{
"id": "claude-opus-5",
"display_name": "Claude Opus 5",
"owned_by": "anthropic",
"thinking": {"levels": ["medium"]}
},
{
"id": "claude-opus-5-low-fast",
"display_name": "Claude Opus 5 Low Fast",
"owned_by": "anthropic"
},
{
"id": "claude-opus-5-high-fast",
"display_name": "Claude Opus 5 High Fast",
"owned_by": "anthropic"
}
]
}`)
models, err := ValidateDevinModelsJSON(payload)
if err != nil {
t.Fatalf("ValidateDevinModelsJSON failed: %v", err)
}
if len(models) != 1 {
t.Fatalf("expected 1 aggregated model, got %d: %+v", len(models), models)
}
if models[0].ID != "devin/claude-opus-5" {
t.Errorf("expected ID devin/claude-opus-5, got %q", models[0].ID)
}
if models[0].Thinking == nil {
t.Fatalf("expected Thinking to be populated")
}
expectedLevels := []string{"low", "medium", "high"}
if !reflect.DeepEqual(models[0].Thinking.Levels, expectedLevels) {
t.Fatalf("expected thinking levels %v, got %v", expectedLevels, models[0].Thinking.Levels)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,24 @@ var knownDevinSuffixes = []string{
"-high-priority",
"-xhigh-priority",
"-max-priority",
"-low-fast",
"-medium-fast",
"-high-fast",
"-xhigh-fast",
"-max-fast",
"-none-fast",
"-thinking-1m",
"-thinking",
"-max-1m",
"-none-1m",
"_none",
"_minimal",
"_low",
"_medium",
"_high",
"_xhigh",
"_max",
"_thinking",
}
// Special private Devin upstream aliases that cannot be dynamically inferred.
@@ -120,6 +138,23 @@ func ResolveDevinChatModelUID(rawModel string, thinkingLevel string, budgetToken
canonicalBase = "gemini-3-8-flash"
}
normalizedUnder := strings.ReplaceAll(canonicalBase, "-", "_")
switch normalizedUnder {
case "model_gpt_5_2":
gptLevels := []string{"none", "low", "medium", "high", "xhigh"}
eff := clampEffort(effort, gptLevels, "low")
return "MODEL_GPT_5_2_" + strings.ToUpper(eff)
case "model_google_gemini_3_0_flash":
gemLevels := []string{"minimal", "low", "medium", "high"}
eff := clampEffort(effort, gemLevels, "high")
return "MODEL_GOOGLE_GEMINI_3_0_FLASH_" + strings.ToUpper(eff)
case "model_claude_4_5_opus":
if effort != "" && effort != "none" {
return "MODEL_CLAUDE_4_5_OPUS_THINKING"
}
return "MODEL_CLAUDE_4_5_OPUS"
}
// 6. Look up dynamic model metadata from the Devin catalog (devin_models.json)
modelInfo := registry.LookupDevinModel(canonicalBase)
if modelInfo == nil && canonicalBase != lowerBase {
@@ -151,6 +186,29 @@ func ResolveDevinChatModelUID(rawModel string, thinkingLevel string, budgetToken
return "glm-5-2-max"
}
return "glm-5-2"
case "glm-5-2-1m":
if effort == "none" {
return "glm-5-2-none-1m"
}
if effort == "max" {
return "glm-5-2-max-1m"
}
return "glm-5-2-1m"
case "claude-opus-4-6", "claude-sonnet-4-6":
if effort != "" && effort != "none" {
return canonicalBase + "-thinking"
}
return canonicalBase
case "claude-opus-4-6-1m":
if effort != "" && effort != "none" {
return "claude-opus-4-6-thinking-1m"
}
return canonicalBase
case "claude-sonnet-4-6-1m":
if effort != "" && effort != "none" {
return "claude-sonnet-4-6-thinking-1m"
}
return canonicalBase
}
// 8. If model has no thinking levels defined in catalog, treat as bare model

View File

@@ -439,6 +439,132 @@ func TestResolveDevinChatModelUID(t *testing.T) {
thinkingLevel: "",
want: "claude-sonnet-4-6",
},
{
name: "claude-opus-4-6 with high maps to claude-opus-4-6-thinking",
rawModel: "devin/claude-opus-4-6",
thinkingLevel: "high",
want: "claude-opus-4-6-thinking",
},
{
name: "claude-sonnet-4-6 with high maps to claude-sonnet-4-6-thinking",
rawModel: "devin/claude-sonnet-4-6",
thinkingLevel: "high",
want: "claude-sonnet-4-6-thinking",
},
{
name: "claude-opus-4-6-1m default maps to claude-opus-4-6-1m",
rawModel: "devin/claude-opus-4-6-1m",
thinkingLevel: "",
want: "claude-opus-4-6-1m",
},
{
name: "claude-opus-4-6-1m with high maps to claude-opus-4-6-thinking-1m",
rawModel: "devin/claude-opus-4-6-1m",
thinkingLevel: "high",
want: "claude-opus-4-6-thinking-1m",
},
{
name: "claude-sonnet-4-6-1m default maps to claude-sonnet-4-6-1m",
rawModel: "devin/claude-sonnet-4-6-1m",
thinkingLevel: "",
want: "claude-sonnet-4-6-1m",
},
{
name: "claude-sonnet-4-6-1m with high maps to claude-sonnet-4-6-thinking-1m",
rawModel: "devin/claude-sonnet-4-6-1m",
thinkingLevel: "high",
want: "claude-sonnet-4-6-thinking-1m",
},
{
name: "glm-5-2-1m default maps to glm-5-2-1m",
rawModel: "devin/glm-5-2-1m",
thinkingLevel: "",
want: "glm-5-2-1m",
},
{
name: "glm-5-2-1m with none maps to glm-5-2-none-1m",
rawModel: "devin/glm-5-2-1m",
thinkingLevel: "none",
want: "glm-5-2-none-1m",
},
{
name: "glm-5-2-1m with max maps to glm-5-2-max-1m",
rawModel: "devin/glm-5-2-1m",
thinkingLevel: "max",
want: "glm-5-2-max-1m",
},
{
name: "MODEL_GPT_5_2 default maps to MODEL_GPT_5_2_LOW",
rawModel: "devin/MODEL_GPT_5_2",
thinkingLevel: "",
want: "MODEL_GPT_5_2_LOW",
},
{
name: "MODEL_GPT_5_2 with none maps to MODEL_GPT_5_2_NONE",
rawModel: "devin/MODEL_GPT_5_2",
thinkingLevel: "none",
want: "MODEL_GPT_5_2_NONE",
},
{
name: "MODEL_GPT_5_2 with medium maps to MODEL_GPT_5_2_MEDIUM",
rawModel: "devin/MODEL_GPT_5_2",
thinkingLevel: "medium",
want: "MODEL_GPT_5_2_MEDIUM",
},
{
name: "MODEL_GPT_5_2 with high maps to MODEL_GPT_5_2_HIGH",
rawModel: "devin/MODEL_GPT_5_2",
thinkingLevel: "high",
want: "MODEL_GPT_5_2_HIGH",
},
{
name: "MODEL_GPT_5_2 with xhigh maps to MODEL_GPT_5_2_XHIGH",
rawModel: "devin/MODEL_GPT_5_2",
thinkingLevel: "xhigh",
want: "MODEL_GPT_5_2_XHIGH",
},
{
name: "MODEL_GOOGLE_GEMINI_3_0_FLASH default maps to HIGH",
rawModel: "devin/MODEL_GOOGLE_GEMINI_3_0_FLASH",
thinkingLevel: "",
want: "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
},
{
name: "MODEL_GOOGLE_GEMINI_3_0_FLASH with minimal maps to MINIMAL",
rawModel: "devin/MODEL_GOOGLE_GEMINI_3_0_FLASH",
thinkingLevel: "minimal",
want: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL",
},
{
name: "MODEL_GOOGLE_GEMINI_3_0_FLASH with low maps to LOW",
rawModel: "devin/MODEL_GOOGLE_GEMINI_3_0_FLASH",
thinkingLevel: "low",
want: "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW",
},
{
name: "MODEL_GOOGLE_GEMINI_3_0_FLASH with medium maps to MEDIUM",
rawModel: "devin/MODEL_GOOGLE_GEMINI_3_0_FLASH",
thinkingLevel: "medium",
want: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM",
},
{
name: "MODEL_GOOGLE_GEMINI_3_0_FLASH with high maps to HIGH",
rawModel: "devin/MODEL_GOOGLE_GEMINI_3_0_FLASH",
thinkingLevel: "high",
want: "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
},
{
name: "MODEL_CLAUDE_4_5_OPUS default maps to MODEL_CLAUDE_4_5_OPUS",
rawModel: "devin/MODEL_CLAUDE_4_5_OPUS",
thinkingLevel: "",
want: "MODEL_CLAUDE_4_5_OPUS",
},
{
name: "MODEL_CLAUDE_4_5_OPUS with high maps to MODEL_CLAUDE_4_5_OPUS_THINKING",
rawModel: "devin/MODEL_CLAUDE_4_5_OPUS",
thinkingLevel: "high",
want: "MODEL_CLAUDE_4_5_OPUS_THINKING",
},
}
for _, tt := range tests {
@@ -467,7 +593,7 @@ func TestResolveDevinChatModelUID_AllCatalogModels(t *testing.T) {
}
// If model defines thinking levels, resolved UID must have an effort suffix
if m.Thinking != nil && len(m.Thinking.Levels) > 0 {
if !HasDevinEffortSuffix(resolved) && baseID != "swe-1-7" && baseID != "glm-5-2" && baseID != "swe-1-6-slow" {
if !HasDevinEffortSuffix(resolved) && baseID != "swe-1-7" && baseID != "glm-5-2" && baseID != "glm-5-2-1m" && baseID != "swe-1-6-slow" && baseID != "model_claude_4_5_opus" {
t.Errorf("thinking model %q with effort %q resolved to bare UID %q without effort suffix", baseID, eff, resolved)
}
}