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
82 changes: 79 additions & 3 deletions cmd/fetch_codex_models/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

const (
Expand All @@ -46,6 +48,8 @@ const (
defaultCodexUserAgent = "codex_cli_rs/0.159.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9"
defaultCodexOriginator = "codex_cli_rs"
accessTokenRefreshLeeway = 30 * time.Second
baselineModelSlug = "gpt-5.5"
baselineModelVisibility = "list"
)

func init() {
Expand Down Expand Up @@ -289,13 +293,85 @@ func fetchModels(ctx context.Context, auth *coreauth.Auth, accessToken, clientVe
return nil, 0, fmt.Errorf("models request failed with status %d: %s", httpResp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}

count, errCount := countModels(bodyBytes)
if errCount != nil {
return nil, 0, errCount
bodyBytes, count, errEnsure := ensureBaselineModel(bodyBytes)
if errEnsure != nil {
return nil, 0, errEnsure
}
return bodyBytes, count, nil
}

func ensureBaselineModel(raw []byte) ([]byte, int, error) {
var payload struct {
Models []json.RawMessage `json:"models"`
}
if errUnmarshal := json.Unmarshal(raw, &payload); errUnmarshal != nil {
return nil, 0, fmt.Errorf("failed to parse response JSON: %w", errUnmarshal)
}
// Keep this check intentionally loose: fetch_codex_models dumps the upstream
// Codex API payload. Strict CPA catalog validation belongs in
// cmd/validate_codex_models and registry.ValidateCodexClientModelsJSON.
if payload.Models == nil {
return nil, 0, fmt.Errorf("response JSON does not contain models array")
}
if len(payload.Models) == 0 {
return raw, 0, nil
}

foundBaseline := false
for i, modelRaw := range payload.Models {
slugResult := gjson.GetBytes(modelRaw, "slug")
if slugResult.Type != gjson.String || strings.TrimSpace(slugResult.Str) != baselineModelSlug {
continue
}
foundBaseline = true
visResult := gjson.GetBytes(modelRaw, "visibility")
if visResult.Type != gjson.String || visResult.Str != baselineModelVisibility {
updated, errSet := sjson.SetBytes(raw, fmt.Sprintf("models.%d.visibility", i), baselineModelVisibility)
if errSet != nil {
return nil, 0, fmt.Errorf("failed to update %s visibility: %w", baselineModelSlug, errSet)
}
raw = updated
}
}

count := len(payload.Models)
if !foundBaseline {
cloned := append([]byte(nil), payload.Models[0]...)
var errSet error
cloned, errSet = sjson.SetBytes(cloned, "slug", baselineModelSlug)
if errSet != nil {
return nil, 0, fmt.Errorf("failed to set %s slug on cloned model: %w", baselineModelSlug, errSet)
}
visResult := gjson.GetBytes(cloned, "visibility")
if visResult.Type != gjson.String || visResult.Str != baselineModelVisibility {
cloned, errSet = sjson.SetBytes(cloned, "visibility", baselineModelVisibility)
if errSet != nil {
return nil, 0, fmt.Errorf("failed to set %s visibility on cloned model: %w", baselineModelSlug, errSet)
}
}
for _, reset := range []struct {
path string
value any
}{
{path: "tool_mode", value: "direct"},
{path: "use_responses_lite", value: false},
{path: "supports_reasoning_effort_updates", value: false},
} {
cloned, errSet = sjson.SetBytes(cloned, reset.path, reset.value)
if errSet != nil {
return nil, 0, fmt.Errorf("failed to set %s %s on cloned model: %w", baselineModelSlug, reset.path, errSet)
}
}
raw, errSet = sjson.SetRawBytes(raw, "models.-1", cloned)
if errSet != nil {
return nil, 0, fmt.Errorf("failed to append %s model: %w", baselineModelSlug, errSet)
}
count++
}

return raw, count, nil
}

func codexModelsURL(clientVersion string) (string, error) {
u, err := url.Parse(codexModelsBaseURL + codexModelsPath)
if err != nil {
Expand Down
20 changes: 18 additions & 2 deletions cmd/fetch_devin_models/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,15 @@ var devinCompoundSuffixes = []struct {
{suffix: "-thinking", effort: ""},
{suffix: "-max-1m", effort: "max", readd: "-1m"},
{suffix: "-none-1m", effort: "none", readd: "-1m"},
// Lightning tier keeps the effort before the -lightning suffix upstream
// (e.g. swe-2-high-lightning), so it aggregates as <base>-lightning.
{suffix: "-minimal-lightning", effort: "minimal", readd: "-lightning"},
{suffix: "-low-lightning", effort: "low", readd: "-lightning"},
{suffix: "-medium-lightning", effort: "medium", readd: "-lightning"},
{suffix: "-high-lightning", effort: "high", readd: "-lightning"},
{suffix: "-xhigh-lightning", effort: "xhigh", readd: "-lightning"},
{suffix: "-max-lightning", effort: "max", readd: "-lightning"},
{suffix: "-none-lightning", effort: "none", readd: "-lightning"},
}

var devinSimpleEffortSuffixes = []struct {
Expand Down Expand Up @@ -571,12 +580,19 @@ func aggregateModels(raw []rawDevinModel) []devinModelJSON {
}
}

// Lightning display names strip both the effort word and "Lightning";
// re-append it so swe-2-lightning shows as "SWE-2 Lightning".
displayName := entry.displayName
if strings.HasSuffix(id, "-lightning") && !strings.HasSuffix(strings.ToLower(displayName), "lightning") {
displayName = strings.TrimSpace(displayName + " Lightning")
}

res = append(res, devinModelJSON{
ID: entry.baseID,
Object: "model",
Type: "devin",
OwnedBy: vendorName(entry.vendorID, entry.baseID),
DisplayName: entry.displayName,
DisplayName: displayName,
ContextLength: entry.contextLength,
MaxCompletionTokens: 64000,
SupportedInputModalities: modalities,
Expand All @@ -595,7 +611,7 @@ var devinDisplayNameSuffixes = []string{
" Low Thinking", " Medium Thinking", " High Thinking", " XHigh Thinking",
" Max Thinking", " No Thinking",
" Low", " Medium", " High", " XHigh", " Max", " None", " Minimal",
" Thinking", " Fast",
" Thinking", " Fast", " Lightning",
}

func cleanDisplayName(label string) string {
Expand Down
52 changes: 24 additions & 28 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/buildinfo"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cmd"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/githubauth"
"github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/homeplugins"
"github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
Expand Down Expand Up @@ -123,6 +124,7 @@ func main() {
var discoverExclude []string
var vertexImport string
var vertexImportPrefix string
var siwcBind string
var configPath string
var password string
var homeJWT string
Expand Down Expand Up @@ -153,13 +155,14 @@ func main() {
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
flag.StringVar(&vertexImportPrefix, "vertex-import-prefix", "", "Prefix for Vertex model namespacing (use with -vertex-import)")
flag.StringVar(&siwcBind, "siwc-bind", "", "Bind Sign in with ChatGPT (SIWC) credentials to a Codex credential file")
flag.StringVar(&password, "password", "", "")
flag.StringVar(&homeJWT, "home-jwt", "", "Home control plane JWT for mTLS certificate bootstrap and connection")
flag.BoolVar(&homeDisableClusterDiscovery, "home-disable-cluster-discovery", false, "Disable Home CLUSTER NODES discovery and keep using the configured -home-jwt address")
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
flag.StringVar(&managementBaseURL, "management-base-url", "", "Base URL of remote management API for TUI client mode (e.g. https://proxy.example.com)")
flag.BoolVar(&localModel, "local-model", false, "Use embedded models.json and codex_client_models.json only, skip remote model catalog fetching")
flag.BoolVar(&localModel, "local-model", false, "Use embedded model catalogs unless models.catalog, models.codex-catalog, or models.devin-catalog explicitly overrides the source")

flag.CommandLine.Usage = func() {
out := flag.CommandLine.Output()
Expand Down Expand Up @@ -567,7 +570,7 @@ func main() {
log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
return
}
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
if errCommit := persistSeededConfig(context.Background(), configFilePath, gitStoreInst.PersistConfig); errCommit != nil {
log.Errorf("failed to commit initial git-backed config: %v", errCommit)
return
}
Expand Down Expand Up @@ -646,6 +649,7 @@ func main() {
} else {
cfg.AuthDir = resolvedAuthDir
}
githubauth.SetToken(cfg.GitHubToken)
managementasset.SetCurrentConfig(cfg)

// Create login options to be used in authentication flows.
Expand All @@ -654,7 +658,7 @@ func main() {
CallbackPort: oauthCallbackPort,
}

commandMode := vertexImport != "" || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || kimiAILogin || xaiLogin || devinLogin || metaLogin
commandMode := vertexImport != "" || siwcBind != "" || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || kimiAILogin || xaiLogin || devinLogin || metaLogin
cloudConfigMissing := isCloudDeploy && !configFileExists
homeMode := configLoadedFromHome || (cfg != nil && cfg.Home.Enabled)
exampleAPIKeySafeMode := shouldEnableExampleAPIKeySafeMode(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode)
Expand Down Expand Up @@ -712,6 +716,9 @@ func main() {
if vertexImport != "" {
// Handle Vertex service account import
cmd.DoVertexImport(cfg, vertexImport, vertexImportPrefix)
} else if siwcBind != "" {
// Handle Sign in with ChatGPT (SIWC) credential binding
cmd.DoSIWCBind(cfg, siwcBind, options)
} else if antigravityLogin {
// Handle Antigravity login
cmd.DoAntigravityLogin(cfg, options)
Expand Down Expand Up @@ -742,14 +749,14 @@ func main() {
return
}
if localModel && (!tuiMode || standalone) {
log.Info("Local model mode: using embedded model catalogs, remote model updates disabled")
log.Info("Local model mode: using embedded catalogs unless an explicit catalog source is configured")
}
if tuiMode {
if standalone {
// Standalone mode: start an embedded local server and connect TUI client to it.
managementasset.StartAutoUpdater(context.Background(), configFilePath)
misc.StartAntigravityVersionUpdater(context.Background())
startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
registry.SetLocalModelCatalogs(localModel)
hook := tui.NewLogHook(2000)
hook.SetFormatter(&logging.LogFormatter{})
log.AddHook(hook)
Expand Down Expand Up @@ -824,7 +831,7 @@ func main() {
// Start the main proxy service
managementasset.StartAutoUpdater(context.Background(), configFilePath)
misc.StartAntigravityVersionUpdater(context.Background())
startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
registry.SetLocalModelCatalogs(localModel)
cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
}
}
Expand All @@ -850,29 +857,18 @@ func resolveManagementBaseURL(flagURL string, cfg *config.Config) string {
return fmt.Sprintf("http://127.0.0.1:%d", port)
}

// modelCatalogUpdaterPlan decides which remote model catalogs should refresh.
// Codex client and Devin catalogs still refresh under Home mode because
// template metadata and Devin models stay edge-local.
func modelCatalogUpdaterPlan(localModel, homeEnabled bool) (startModels, startCodexClient, startDevin bool) {
if localModel {
return false, false, false
}
return !homeEnabled, true, true
}

func startModelCatalogUpdaters(localModel, homeEnabled bool) {
startModels, startCodexClient, startDevin := modelCatalogUpdaterPlan(localModel, homeEnabled)
if startCodexClient {
registry.StartCodexClientModelsUpdater(context.Background())
}
if startDevin {
registry.StartDevinModelsUpdater(context.Background())
}
if startModels {
registry.StartModelsUpdater(context.Background())
} else if homeEnabled {
log.Info("Home mode: remote models.json updates disabled; Codex client model list follows Home model IDs")
// persistSeededConfig persists a config file freshly copied from the template. The file
// is first rewritten as the startup load would, so a plaintext management secret never
// reaches the remote store. When that fails, the copy is removed and an error returned,
// so every start refuses the template instead of running from an unpersisted config.
func persistSeededConfig(ctx context.Context, configFilePath string, persist func(context.Context) error) error {
if errPrepare := config.PrepareConfigFileForUpload(configFilePath); errPrepare != nil {
if errRemove := os.Remove(configFilePath); errRemove != nil {
log.WithError(errRemove).Warn("failed to remove the config copied from the template")
}
return fmt.Errorf("prepare config for upload: %w", errPrepare)
}
return persist(ctx)
}

func pluginBootstrapConfigPath(args []string, defaultPath string) string {
Expand Down
Loading
Loading