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
3 changes: 0 additions & 3 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
"github.com/router-for-me/CLIProxyAPI/v6/internal/tui"
Expand Down Expand Up @@ -495,7 +494,6 @@ func main() {
if standalone {
// Standalone mode: start an embedded local server and connect TUI client to it.
managementasset.StartAutoUpdater(context.Background(), configFilePath)
registry.StartModelsUpdater(context.Background())
hook := tui.NewLogHook(2000)
hook.SetFormatter(&logging.LogFormatter{})
log.AddHook(hook)
Expand Down Expand Up @@ -568,7 +566,6 @@ func main() {
} else {
// Start the main proxy service
managementasset.StartAutoUpdater(context.Background(), configFilePath)
registry.StartModelsUpdater(context.Background())
cmd.StartService(cfg, configFilePath, password)
}
}
Expand Down
6 changes: 5 additions & 1 deletion internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -1505,7 +1505,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
planType := ""
hashAccountID := ""
if claims != nil {
planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
planType = registry.NormalizeCodexPlanType(claims.CodexAuthInfo.ChatgptPlanType)
if accountID := claims.GetAccountID(); accountID != "" {
digest := sha256.Sum256([]byte(accountID))
hashAccountID = hex.EncodeToString(digest[:])[:8]
Expand All @@ -1523,6 +1523,10 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
Metadata: map[string]any{
"email": tokenStorage.Email,
"account_id": tokenStorage.AccountID,
"plan_type": planType,
},
Attributes: map[string]string{
"plan_type": planType,
},
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
Expand Down
8 changes: 7 additions & 1 deletion internal/auth/codex/filename.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,11 @@ func normalizePlanTypeForFilename(planType string) string {
for i, part := range parts {
parts[i] = strings.ToLower(strings.TrimSpace(part))
}
return strings.Join(parts, "-")
normalized := strings.Join(parts, "-")
switch normalized {
case "business", "enterprise", "edu", "education":
return "team"
default:
return normalized
}
}
11 changes: 11 additions & 0 deletions internal/auth/codex/filename_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package codex

import "testing"

func TestCredentialFileNameNormalizesTeamAliases(t *testing.T) {
got := CredentialFileName("tester@example.com", "business", "deadbeef", true)
want := "codex-deadbeef-tester@example.com-team.json"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
158 changes: 158 additions & 0 deletions internal/registry/codex_catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package registry

import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)

func GetCodexModelsForPlan(planType string) []*ModelInfo {
switch NormalizeCodexPlanType(planType) {
case "pro":
return GetCodexProModels()
case "plus":
return GetCodexPlusModels()
case "team":
return GetCodexTeamModels()
case "free":
fallthrough
default:
return GetCodexFreeModels()
}
}

func GetCodexModelsUnion() []*ModelInfo {
data := getModels()
sections := [][]*ModelInfo{
data.CodexFree,
data.CodexTeam,
data.CodexPlus,
data.CodexPro,
}
seen := make(map[string]struct{})
out := make([]*ModelInfo, 0)
for _, models := range sections {
for _, model := range models {
if model == nil || strings.TrimSpace(model.ID) == "" {
continue
}
if _, ok := seen[model.ID]; ok {
continue
}
seen[model.ID] = struct{}{}
out = append(out, cloneModelInfo(model))
}
}
return out
}

func NormalizeCodexPlanType(planType string) string {
switch strings.ToLower(strings.TrimSpace(planType)) {
case "free":
return "free"
case "team", "business", "enterprise", "edu", "education":
return "team"
case "plus":
return "plus"
case "pro":
return "pro"
default:
return ""
}
}

func ResolveCodexPlanType(attributes map[string]string, metadata map[string]any) string {
if attributes != nil {
for _, key := range []string{"plan_type", "chatgpt_plan_type"} {
if plan := NormalizeCodexPlanType(attributes[key]); plan != "" {
return plan
}
}
}
plan, _ := EnsureCodexPlanTypeMetadata(metadata)
return plan
}

func EnsureCodexPlanTypeMetadata(metadata map[string]any) (string, bool) {
if metadata == nil {
return "", false
}
for _, key := range []string{"plan_type", "chatgpt_plan_type"} {
if raw, ok := metadata[key].(string); ok {
if plan := NormalizeCodexPlanType(raw); plan != "" {
current, _ := metadata["plan_type"].(string)
if strings.TrimSpace(current) != plan {
metadata["plan_type"] = plan
return plan, true
}
return plan, false
}
}
}
idToken := firstString(metadata, "id_token")
if idToken == "" {
idToken = nestedString(metadata, "token", "id_token")
}
if idToken == "" {
idToken = nestedString(metadata, "tokens", "id_token")
}
if idToken == "" {
return "", false
}
plan, err := extractCodexPlanTypeFromJWT(idToken)
if err != nil {
return "", false
}
if plan == "" {
return "", false
}
metadata["plan_type"] = plan
return plan, true
}

func firstString(metadata map[string]any, key string) string {
if metadata == nil {
return ""
}
if value, ok := metadata[key].(string); ok {
return strings.TrimSpace(value)
}
return ""
}

func nestedString(metadata map[string]any, parent, key string) string {
if metadata == nil {
return ""
}
raw, ok := metadata[parent]
if !ok {
return ""
}
child, ok := raw.(map[string]any)
if !ok {
return ""
}
value, _ := child[key].(string)
return strings.TrimSpace(value)
}

func extractCodexPlanTypeFromJWT(token string) (string, error) {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid jwt format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
var claims struct {
Auth struct {
PlanType string `json:"chatgpt_plan_type"`
} `json:"https://api.openai.com/auth"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", err
}
return NormalizeCodexPlanType(claims.Auth.PlanType), nil
}
106 changes: 106 additions & 0 deletions internal/registry/codex_catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package registry

import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
)

func TestEnsureCodexPlanTypeMetadataExtractsFromJWT(t *testing.T) {
metadata := map[string]any{
"type": "codex",
"id_token": testCodexJWT(t, "team"),
}
plan, changed := EnsureCodexPlanTypeMetadata(metadata)
if !changed {
t.Fatal("expected metadata to be updated from id_token")
}
if plan != "team" {
t.Fatalf("expected team plan, got %q", plan)
}
if got, _ := metadata["plan_type"].(string); got != "team" {
t.Fatalf("expected metadata plan_type team, got %#v", metadata["plan_type"])
}
}

func TestEnsureCodexPlanTypeMetadataNormalizesAliases(t *testing.T) {
metadata := map[string]any{
"type": "codex",
"plan_type": "business",
}
plan, changed := EnsureCodexPlanTypeMetadata(metadata)
if !changed {
t.Fatal("expected alias plan_type to be normalized")
}
if plan != "team" {
t.Fatalf("expected normalized team plan, got %q", plan)
}
if got, _ := metadata["plan_type"].(string); got != "team" {
t.Fatalf("expected metadata plan_type team, got %#v", metadata["plan_type"])
}
}

func TestGetCodexModelsForPlanUsesSafeFallback(t *testing.T) {
models := GetCodexModelsForPlan("unknown")
if len(models) == 0 {
t.Fatal("expected codex models for unknown plan fallback")
}
for _, model := range models {
if model == nil {
continue
}
if model.ID == "gpt-5.4" || model.ID == "gpt-5.3-codex-spark" {
t.Fatalf("expected unknown plan to avoid higher-tier model %q", model.ID)
}
}
}

func TestGetStaticModelDefinitionsByChannelCodexReturnsTierUnion(t *testing.T) {
models := GetStaticModelDefinitionsByChannel("codex")
if len(models) == 0 {
t.Fatal("expected codex static model definitions")
}
seen := make(map[string]struct{}, len(models))
for _, model := range models {
if model == nil {
continue
}
if _, ok := seen[model.ID]; ok {
t.Fatalf("duplicate codex model %q in union", model.ID)
}
seen[model.ID] = struct{}{}
}
for _, required := range []string{"gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.4"} {
if _, ok := seen[required]; !ok {
t.Fatalf("expected codex union to include %q", required)
}
}
}

func TestLookupStaticModelInfoFindsCodexTierModel(t *testing.T) {
model := LookupStaticModelInfo("gpt-5.3-codex-spark")
if model == nil {
t.Fatal("expected spark model lookup to succeed")
}
if !strings.EqualFold(model.DisplayName, "GPT 5.3 Codex Spark") {
t.Fatalf("unexpected display name: %+v", model)
}
}

func testCodexJWT(t *testing.T, planType string) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payloadRaw, err := json.Marshal(map[string]any{
"email": "tester@example.com",
"https://api.openai.com/auth": map[string]any{
"chatgpt_account_id": "acct_123",
"chatgpt_plan_type": planType,
},
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
payload := base64.RawURLEncoding.EncodeToString(payloadRaw)
return header + "." + payload + ".signature"
}
4 changes: 2 additions & 2 deletions internal/registry/model_definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
case "aistudio":
return GetAIStudioModels()
case "codex":
return GetCodexProModels()
return GetCodexModelsUnion()
case "qwen":
return GetQwenModels()
case "iflow":
Expand Down Expand Up @@ -209,7 +209,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
data.Vertex,
data.GeminiCLI,
data.AIStudio,
data.CodexPro,
GetCodexModelsUnion(),
data.Qwen,
data.IFlow,
data.Kimi,
Expand Down
20 changes: 16 additions & 4 deletions internal/registry/model_updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type modelStore struct {
var modelsCatalogStore = &modelStore{}

var updaterOnce sync.Once
var updaterDone = make(chan struct{})

func init() {
// Load embedded data as fallback on startup.
Expand All @@ -43,13 +44,24 @@ func init() {
}

// StartModelsUpdater runs a one-time models refresh on startup.
// It blocks until the startup fetch attempt finishes so service initialization
// can wait for the refreshed catalog before registering auth-backed models.
// It returns immediately and performs the network refresh in the background.
// Every invocation registers an onUpdated callback for the same one-time startup
// attempt, so callers added after initialization still observe completion.
// Callbacks also run when the refresh falls back to the embedded catalog.
// Safe to call multiple times; only one refresh will run.
func StartModelsUpdater(ctx context.Context) {
func StartModelsUpdater(ctx context.Context, onUpdated func()) {
updaterOnce.Do(func() {
runModelsUpdater(ctx)
go func() {
defer close(updaterDone)
runModelsUpdater(ctx)
}()
})
if onUpdated != nil {
go func() {
<-updaterDone
onUpdated()
}()
}
}

func runModelsUpdater(ctx context.Context) {
Expand Down
Loading