mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-17 13:08:57 +08:00
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:
@@ -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]
|
||||
})
|
||||
}
|
||||
|
||||
136
cmd/fetch_devin_models/main_test.go
Normal file
136
cmd/fetch_devin_models/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user