mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-07 16:23:58 +08:00
525 lines
14 KiB
Go
525 lines
14 KiB
Go
// Command fetch_devin_models connects to the Devin/Codeium Connect-RPC API using
|
|
// stored auth credentials and saves the dynamically fetched model list to a
|
|
// JSON file for inspection or offline catalog updates.
|
|
//
|
|
// Usage:
|
|
//
|
|
// go run ./cmd/fetch_devin_models [flags]
|
|
//
|
|
// Flags:
|
|
//
|
|
// --auths-dir <path> Directory containing auth JSON files (default: config auth-dir)
|
|
// --config <path> Config file path (default: "config.yaml")
|
|
// --output <path> Output JSON file path (default: "devin_models.json")
|
|
// --raw Dump all raw variants without aggregating thinking levels (default: false)
|
|
// --pretty Pretty-print the output JSON (default: true)
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
|
log "github.com/sirupsen/logrus"
|
|
"google.golang.org/protobuf/encoding/protowire"
|
|
)
|
|
|
|
const (
|
|
devinGetCliModelConfigsURL = "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetCliModelConfigs"
|
|
devinDefaultUserAgent = "connect-go/1.19.1 (go1.25.0)"
|
|
)
|
|
|
|
func init() {
|
|
logging.SetupBaseLogger()
|
|
log.SetLevel(log.InfoLevel)
|
|
}
|
|
|
|
type devinCatalogOutput struct {
|
|
Devin []devinModelJSON `json:"devin"`
|
|
}
|
|
|
|
type devinModelJSON struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object"`
|
|
Type string `json:"type"`
|
|
OwnedBy string `json:"owned_by"`
|
|
DisplayName string `json:"display_name"`
|
|
ContextLength int `json:"context_length,omitempty"`
|
|
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
|
SupportedInputModalities []string `json:"supportedInputModalities,omitempty"`
|
|
SupportedOutputModalities []string `json:"supportedOutputModalities,omitempty"`
|
|
Thinking *devinThinkingJSON `json:"thinking,omitempty"`
|
|
}
|
|
|
|
type devinThinkingJSON struct {
|
|
Levels []string `json:"levels,omitempty"`
|
|
}
|
|
|
|
type rawDevinModel struct {
|
|
UID string
|
|
Label string
|
|
ContextLength int
|
|
Multimodal bool
|
|
VendorID uint64
|
|
EffortTier string
|
|
}
|
|
|
|
func main() {
|
|
var authsDir string
|
|
var configPath string
|
|
var outputPath string
|
|
var rawOutput bool
|
|
var pretty bool
|
|
|
|
flag.StringVar(&authsDir, "auths-dir", "", "Directory containing auth JSON files (overrides config auth-dir)")
|
|
flag.StringVar(&configPath, "config", "", "Configure File Path")
|
|
flag.StringVar(&outputPath, "output", "devin_models.json", "Output JSON file path")
|
|
flag.BoolVar(&rawOutput, "raw", false, "Dump all raw model configurations without aggregation")
|
|
flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON")
|
|
flag.Parse()
|
|
|
|
authsDirOverridden := false
|
|
flag.Visit(func(f *flag.Flag) {
|
|
if f.Name == "auths-dir" {
|
|
authsDirOverridden = true
|
|
}
|
|
})
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: cannot get working directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if strings.TrimSpace(configPath) == "" {
|
|
configPath = filepath.Join(wd, "config.yaml")
|
|
}
|
|
cfg, err := config.LoadConfigOptional(configPath, false)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: failed to load config file %s: %v\n", configPath, err)
|
|
os.Exit(1)
|
|
}
|
|
if cfg == nil {
|
|
cfg = &config.Config{}
|
|
}
|
|
|
|
if !authsDirOverridden {
|
|
authsDir = cfg.AuthDir
|
|
} else if strings.TrimSpace(authsDir) != "" && !strings.HasPrefix(strings.TrimSpace(authsDir), "~") && !filepath.IsAbs(authsDir) {
|
|
authsDir = filepath.Join(wd, authsDir)
|
|
}
|
|
if authsDir, err = util.ResolveAuthDir(authsDir); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: failed to resolve auth directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if !filepath.IsAbs(outputPath) {
|
|
outputPath = filepath.Join(wd, outputPath)
|
|
}
|
|
|
|
fmt.Printf("Scanning auth files in: %s\n", authsDir)
|
|
|
|
fileStore := sdkauth.NewFileTokenStore()
|
|
fileStore.SetBaseDir(authsDir)
|
|
|
|
ctx := context.Background()
|
|
authList, err := fileStore.List(ctx)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var chosen *coreauth.Auth
|
|
for _, a := range authList {
|
|
if a == nil || a.Disabled {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(a.Provider), "devin") {
|
|
chosen = a
|
|
break
|
|
}
|
|
}
|
|
if chosen == nil {
|
|
fmt.Fprintf(os.Stderr, "error: no enabled devin auth found in %s\n", authsDir)
|
|
os.Exit(1)
|
|
}
|
|
|
|
apiKey := ""
|
|
if chosen.Attributes != nil {
|
|
apiKey = chosen.Attributes["api_key"]
|
|
}
|
|
if apiKey == "" && chosen.Metadata != nil {
|
|
if val, ok := chosen.Metadata["api_key"].(string); ok {
|
|
apiKey = val
|
|
}
|
|
}
|
|
if apiKey == "" {
|
|
fmt.Fprintf(os.Stderr, "error: devin auth %s has no api_key\n", chosen.ID)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
|
|
fmt.Println("Fetching Devin model catalog from upstream...")
|
|
|
|
rawModels, err := fetchRawDevinModels(ctx, cfg, chosen, apiKey)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: fetch failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("Successfully fetched %d raw model configs from upstream.\n", len(rawModels))
|
|
|
|
var outputJSON devinCatalogOutput
|
|
if rawOutput {
|
|
outputJSON.Devin = formatRawModels(rawModels)
|
|
} else {
|
|
outputJSON.Devin = aggregateModels(rawModels)
|
|
}
|
|
|
|
var encoded []byte
|
|
if pretty {
|
|
encoded, err = json.MarshalIndent(outputJSON, "", " ")
|
|
} else {
|
|
encoded, err = json.Marshal(outputJSON)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: failed to encode JSON: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: failed to create output directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := os.WriteFile(outputPath, encoded, 0o644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: failed to write output file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("Catalog written to: %s (%d models)\n", outputPath, len(outputJSON.Devin))
|
|
}
|
|
|
|
func fetchRawDevinModels(ctx context.Context, cfg *config.Config, auth *coreauth.Auth, apiKey string) ([]rawDevinModel, error) {
|
|
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
f1Bytes := helps.BuildDevinClientMetadataBytes(apiKey, "", "")
|
|
var reqBody []byte
|
|
reqBody = protowire.AppendTag(reqBody, 1, protowire.BytesType)
|
|
reqBody = protowire.AppendBytes(reqBody, f1Bytes)
|
|
|
|
req, err := http.NewRequestWithContext(fetchCtx, http.MethodPost, devinGetCliModelConfigsURL, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Basic "+apiKey+"-"+apiKey)
|
|
req.Header.Set("Content-Type", "application/proto")
|
|
req.Header.Set("Connect-Protocol-Version", "1")
|
|
req.Header.Set("User-Agent", devinDefaultUserAgent)
|
|
|
|
httpClient := helps.NewProxyAwareHTTPClient(fetchCtx, cfg, auth, 30*time.Second)
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer func() {
|
|
if errClose := resp.Body.Close(); errClose != nil {
|
|
log.Errorf("failed to close response body: %v", errClose)
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("upstream returned status %d: %s", resp.StatusCode, proxyutil.Redact(string(bodySnippet)))
|
|
}
|
|
|
|
respBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response body: %w", err)
|
|
}
|
|
|
|
return parseRawDevinModelsProto(respBytes)
|
|
}
|
|
|
|
func parseRawDevinModelsProto(b []byte) ([]rawDevinModel, error) {
|
|
var results []rawDevinModel
|
|
for len(b) > 0 {
|
|
num, typ, n := protowire.ConsumeTag(b)
|
|
if n < 0 {
|
|
return nil, errors.New("malformed protobuf tag")
|
|
}
|
|
b = b[n:]
|
|
|
|
if num == 1 && typ == protowire.BytesType {
|
|
subBytes, m := protowire.ConsumeBytes(b)
|
|
if m < 0 {
|
|
return nil, errors.New("malformed protobuf submessage")
|
|
}
|
|
b = b[m:]
|
|
|
|
model := parseSingleModelConfig(subBytes)
|
|
if model.UID != "" {
|
|
results = append(results, model)
|
|
}
|
|
} else {
|
|
skip := protowire.ConsumeFieldValue(num, typ, b)
|
|
if skip < 0 {
|
|
return nil, errors.New("failed to skip field")
|
|
}
|
|
b = b[skip:]
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func parseSingleModelConfig(b []byte) rawDevinModel {
|
|
var m rawDevinModel
|
|
for len(b) > 0 {
|
|
num, typ, n := protowire.ConsumeTag(b)
|
|
if n < 0 {
|
|
break
|
|
}
|
|
b = b[n:]
|
|
|
|
switch num {
|
|
case 1: // label
|
|
if typ == protowire.BytesType {
|
|
val, mLen := protowire.ConsumeString(b)
|
|
if mLen >= 0 {
|
|
m.Label = val
|
|
b = b[mLen:]
|
|
continue
|
|
}
|
|
}
|
|
case 5: // multimodal
|
|
if typ == protowire.VarintType {
|
|
val, mLen := protowire.ConsumeVarint(b)
|
|
if mLen >= 0 {
|
|
m.Multimodal = (val == 1)
|
|
b = b[mLen:]
|
|
continue
|
|
}
|
|
}
|
|
case 10: // vendor
|
|
if typ == protowire.VarintType {
|
|
val, mLen := protowire.ConsumeVarint(b)
|
|
if mLen >= 0 {
|
|
m.VendorID = val
|
|
b = b[mLen:]
|
|
continue
|
|
}
|
|
}
|
|
case 18: // context length
|
|
if typ == protowire.VarintType {
|
|
val, mLen := protowire.ConsumeVarint(b)
|
|
if mLen >= 0 {
|
|
m.ContextLength = int(val)
|
|
b = b[mLen:]
|
|
continue
|
|
}
|
|
}
|
|
case 22: // chat_model_uid
|
|
if typ == protowire.BytesType {
|
|
val, mLen := protowire.ConsumeString(b)
|
|
if mLen >= 0 {
|
|
m.UID = val
|
|
b = b[mLen:]
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
skip := protowire.ConsumeFieldValue(num, typ, b)
|
|
if skip < 0 {
|
|
break
|
|
}
|
|
b = b[skip:]
|
|
}
|
|
return m
|
|
}
|
|
|
|
func vendorName(id uint64) string {
|
|
switch id {
|
|
case 1:
|
|
return "cognition"
|
|
case 2:
|
|
return "openai"
|
|
case 3:
|
|
return "anthropic"
|
|
case 4:
|
|
return "google"
|
|
case 6:
|
|
return "deepseek"
|
|
case 7:
|
|
return "moonshot"
|
|
case 9:
|
|
return "zhipu"
|
|
case 11:
|
|
return "nvidia"
|
|
default:
|
|
return "devin"
|
|
}
|
|
}
|
|
|
|
func formatRawModels(raw []rawDevinModel) []devinModelJSON {
|
|
res := make([]devinModelJSON, 0, len(raw))
|
|
for _, r := range raw {
|
|
modalities := []string{"text"}
|
|
if r.Multimodal {
|
|
modalities = append(modalities, "image")
|
|
}
|
|
res = append(res, devinModelJSON{
|
|
ID: r.UID,
|
|
Object: "model",
|
|
Type: "devin",
|
|
OwnedBy: vendorName(r.VendorID),
|
|
DisplayName: r.Label,
|
|
ContextLength: r.ContextLength,
|
|
MaxCompletionTokens: 64000,
|
|
SupportedInputModalities: modalities,
|
|
SupportedOutputModalities: []string{"text"},
|
|
})
|
|
}
|
|
return res
|
|
}
|
|
|
|
func aggregateModels(raw []rawDevinModel) []devinModelJSON {
|
|
type aggEntry struct {
|
|
baseID string
|
|
displayName string
|
|
vendorID uint64
|
|
contextLength int
|
|
multimodal bool
|
|
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
|
|
}
|
|
}
|
|
|
|
entry, exists := grouped[base]
|
|
if !exists {
|
|
entry = &aggEntry{
|
|
baseID: base,
|
|
displayName: cleanDisplayName(r.Label),
|
|
vendorID: r.VendorID,
|
|
contextLength: r.ContextLength,
|
|
multimodal: r.Multimodal,
|
|
levels: make(map[string]struct{}),
|
|
}
|
|
grouped[base] = entry
|
|
order = append(order, base)
|
|
}
|
|
|
|
if r.Multimodal {
|
|
entry.multimodal = true
|
|
}
|
|
if r.ContextLength > entry.contextLength {
|
|
entry.contextLength = r.ContextLength
|
|
}
|
|
if level != "" {
|
|
entry.levels[level] = struct{}{}
|
|
}
|
|
}
|
|
|
|
res := make([]devinModelJSON, 0, len(order))
|
|
for _, id := range order {
|
|
entry := grouped[id]
|
|
modalities := []string{"text"}
|
|
if entry.multimodal {
|
|
modalities = append(modalities, "image")
|
|
}
|
|
|
|
var thinking *devinThinkingJSON
|
|
if len(entry.levels) > 0 {
|
|
levelsList := make([]string, 0, len(entry.levels))
|
|
for l := range entry.levels {
|
|
levelsList = append(levelsList, l)
|
|
}
|
|
sortLevels(levelsList)
|
|
thinking = &devinThinkingJSON{
|
|
Levels: levelsList,
|
|
}
|
|
}
|
|
|
|
res = append(res, devinModelJSON{
|
|
ID: entry.baseID,
|
|
Object: "model",
|
|
Type: "devin",
|
|
OwnedBy: vendorName(entry.vendorID),
|
|
DisplayName: entry.displayName,
|
|
ContextLength: entry.contextLength,
|
|
MaxCompletionTokens: 64000,
|
|
SupportedInputModalities: modalities,
|
|
SupportedOutputModalities: []string{"text"},
|
|
Thinking: thinking,
|
|
})
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func cleanDisplayName(label string) string {
|
|
clean := label
|
|
for _, s := range []string{" Low", " Medium", " High", " XHigh", " Max", " Minimal", " None", " Priority"} {
|
|
clean = strings.TrimSuffix(clean, s)
|
|
}
|
|
return clean
|
|
}
|
|
|
|
func sortLevels(levels []string) {
|
|
rank := map[string]int{
|
|
"none": 0,
|
|
"minimal": 1,
|
|
"low": 2,
|
|
"medium": 3,
|
|
"high": 4,
|
|
"xhigh": 5,
|
|
"max": 6,
|
|
}
|
|
sort.Slice(levels, func(i, j int) bool {
|
|
rI, okI := rank[levels[i]]
|
|
if !okI {
|
|
rI = 99
|
|
}
|
|
rJ, okJ := rank[levels[j]]
|
|
if !okJ {
|
|
rJ = 99
|
|
}
|
|
return rI < rJ
|
|
})
|
|
}
|