Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
305
backend/cmd/fetch_antigravity_models/main.go
Normal file
305
backend/cmd/fetch_antigravity_models/main.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
// Command fetch_antigravity_models connects to the Antigravity API using the
|
||||
// stored auth credentials and saves the dynamically fetched model list to a
|
||||
// JSON file for inspection or offline use.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/fetch_antigravity_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: "antigravity_models.json")
|
||||
// --pretty Pretty-print the output JSON (default: true)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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/misc"
|
||||
"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"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
antigravityBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
|
||||
antigravitySandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
antigravityBaseURLProd = "https://cloudcode-pa.googleapis.com"
|
||||
antigravityModelsPath = "/v1internal:fetchAvailableModels"
|
||||
)
|
||||
|
||||
func init() {
|
||||
logging.SetupBaseLogger()
|
||||
log.SetLevel(log.InfoLevel)
|
||||
}
|
||||
|
||||
// modelOutput wraps the fetched model list with fetch metadata.
|
||||
type modelOutput struct {
|
||||
Models []modelEntry `json:"models"`
|
||||
}
|
||||
|
||||
// modelEntry contains only the fields we want to keep for static model definitions.
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var authsDir string
|
||||
var configPath string
|
||||
var outputPath string
|
||||
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", "antigravity_models.json", "Output JSON file path")
|
||||
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)
|
||||
|
||||
// Load all auth records from the directory.
|
||||
fileStore := sdkauth.NewFileTokenStore()
|
||||
fileStore.SetBaseDir(authsDir)
|
||||
|
||||
ctx := context.Background()
|
||||
auths, err := fileStore.List(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "error: no auth files found in %s\n", authsDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Find the first enabled antigravity auth.
|
||||
var chosen *coreauth.Auth
|
||||
for _, a := range auths {
|
||||
if a == nil || a.Disabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(a.Provider), "antigravity") {
|
||||
chosen = a
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
fmt.Fprintf(os.Stderr, "error: no enabled antigravity auth found in %s\n", authsDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
|
||||
|
||||
// Fetch models from the upstream Antigravity API.
|
||||
fmt.Println("Fetching Antigravity model list from upstream...")
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
models := fetchModels(fetchCtx, chosen)
|
||||
if len(models) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "warning: no models returned (API may be unavailable or token expired)")
|
||||
} else {
|
||||
fmt.Printf("Fetched %d models.\n", len(models))
|
||||
}
|
||||
|
||||
// Build the output payload.
|
||||
out := modelOutput{
|
||||
Models: models,
|
||||
}
|
||||
|
||||
// Marshal to JSON.
|
||||
var raw []byte
|
||||
if pretty {
|
||||
raw, err = json.MarshalIndent(out, "", " ")
|
||||
} else {
|
||||
raw, err = json.Marshal(out)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to marshal JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = os.WriteFile(outputPath, raw, 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to write output file %s: %v\n", outputPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Model list saved to: %s\n", outputPath)
|
||||
}
|
||||
|
||||
func fetchModels(ctx context.Context, auth *coreauth.Auth) []modelEntry {
|
||||
accessToken := metaStringValue(auth.Metadata, "access_token")
|
||||
if accessToken == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: no access token found in auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
baseURLs := []string{antigravityBaseURLProd, antigravityBaseURLDaily, antigravitySandboxBaseURLDaily}
|
||||
|
||||
for _, baseURL := range baseURLs {
|
||||
modelsURL := baseURL + antigravityModelsPath
|
||||
|
||||
var payload []byte
|
||||
if auth != nil && auth.Metadata != nil {
|
||||
if pid, ok := auth.Metadata["project_id"].(string); ok && strings.TrimSpace(pid) != "" {
|
||||
payload = []byte(fmt.Sprintf(`{"project": "%s"}`, strings.TrimSpace(pid)))
|
||||
}
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
payload = []byte(`{}`)
|
||||
}
|
||||
|
||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, strings.NewReader(string(payload)))
|
||||
if errReq != nil {
|
||||
continue
|
||||
}
|
||||
httpReq.Close = true
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
httpReq.Header.Set("User-Agent", misc.AntigravityUserAgent())
|
||||
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {
|
||||
httpClient.Transport = transport
|
||||
}
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
bodyBytes, errRead := io.ReadAll(httpResp.Body)
|
||||
httpResp.Body.Close()
|
||||
if errRead != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||
continue
|
||||
}
|
||||
|
||||
result := gjson.GetBytes(bodyBytes, "models")
|
||||
if !result.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
var models []modelEntry
|
||||
|
||||
for originalName, modelData := range result.Map() {
|
||||
modelID := strings.TrimSpace(originalName)
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
// Skip internal/experimental models
|
||||
switch modelID {
|
||||
case "chat_20706", "chat_23310", "tab_flash_lite_preview", "tab_jump_flash_lite_preview", "gemini-2.5-flash-thinking", "gemini-2.5-pro":
|
||||
continue
|
||||
}
|
||||
|
||||
displayName := modelData.Get("displayName").String()
|
||||
if displayName == "" {
|
||||
displayName = modelID
|
||||
}
|
||||
|
||||
entry := modelEntry{
|
||||
ID: modelID,
|
||||
Object: "model",
|
||||
OwnedBy: "antigravity",
|
||||
Type: "antigravity",
|
||||
DisplayName: displayName,
|
||||
Name: modelID,
|
||||
Description: displayName,
|
||||
}
|
||||
|
||||
if maxTok := modelData.Get("maxTokens").Int(); maxTok > 0 {
|
||||
entry.ContextLength = int(maxTok)
|
||||
}
|
||||
if maxOut := modelData.Get("maxOutputTokens").Int(); maxOut > 0 {
|
||||
entry.MaxCompletionTokens = int(maxOut)
|
||||
}
|
||||
|
||||
models = append(models, entry)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func metaStringValue(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
336
backend/cmd/fetch_codex_models/main.go
Normal file
336
backend/cmd/fetch_codex_models/main.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
// Command fetch_codex_models connects to the Codex API using stored auth
|
||||
// credentials and saves the dynamically fetched Codex client model catalog to a
|
||||
// JSON file for inspection or offline use.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/fetch_codex_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: "codex_client_models.json")
|
||||
// --client-version <ver> Codex client_version query value (default: "0.144.1")
|
||||
// --pretty Pretty-print the output JSON (default: true)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
codexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
|
||||
"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/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"
|
||||
)
|
||||
|
||||
const (
|
||||
codexModelsBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||
codexModelsPath = "/models"
|
||||
defaultClientVersion = "0.144.1"
|
||||
defaultCodexUserAgent = "codex_cli_rs/0.144.1 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9"
|
||||
defaultCodexOriginator = "codex_cli_rs"
|
||||
accessTokenRefreshLeeway = 30 * time.Second
|
||||
)
|
||||
|
||||
func init() {
|
||||
logging.SetupBaseLogger()
|
||||
log.SetLevel(log.InfoLevel)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var authsDir string
|
||||
var configPath string
|
||||
var outputPath string
|
||||
var clientVersion string
|
||||
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", "codex_client_models.json", "Output JSON file path")
|
||||
flag.StringVar(&clientVersion, "client-version", defaultClientVersion, "Codex client_version query value")
|
||||
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()
|
||||
auths, err := fileStore.List(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "error: no auth files found in %s\n", authsDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
chosen := findCodexAuth(auths)
|
||||
if chosen == nil {
|
||||
fmt.Fprintf(os.Stderr, "error: no enabled codex auth found in %s\n", authsDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
|
||||
|
||||
accessToken, refreshed, err := ensureAccessToken(ctx, fileStore, chosen)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to prepare codex access token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if refreshed {
|
||||
fmt.Println("Refreshed Codex access token.")
|
||||
}
|
||||
|
||||
fmt.Println("Fetching Codex model list from upstream...")
|
||||
|
||||
raw, count, err := fetchModels(ctx, chosen, accessToken, clientVersion)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to fetch codex models: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Fetched %d models.\n", count)
|
||||
|
||||
if pretty {
|
||||
raw, err = prettyJSON(raw)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to format JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err = os.WriteFile(outputPath, raw, 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to write output file %s: %v\n", outputPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Model list saved to: %s\n", outputPath)
|
||||
}
|
||||
|
||||
func findCodexAuth(auths []*coreauth.Auth) *coreauth.Auth {
|
||||
for _, auth := range auths {
|
||||
if auth == nil || auth.Disabled {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
|
||||
continue
|
||||
}
|
||||
if metaStringValue(auth.Metadata, "access_token") == "" && metaStringValue(auth.Metadata, "refresh_token") == "" {
|
||||
continue
|
||||
}
|
||||
return auth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureAccessToken(ctx context.Context, store *sdkauth.FileTokenStore, auth *coreauth.Auth) (string, bool, error) {
|
||||
accessToken := metaStringValue(auth.Metadata, "access_token")
|
||||
if accessToken != "" {
|
||||
if expiresAt, ok := auth.ExpirationTime(); !ok || time.Now().Add(accessTokenRefreshLeeway).Before(expiresAt) {
|
||||
return accessToken, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
refreshToken := metaStringValue(auth.Metadata, "refresh_token")
|
||||
if refreshToken == "" {
|
||||
if accessToken != "" {
|
||||
return accessToken, false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("missing access_token and refresh_token")
|
||||
}
|
||||
|
||||
svc := codexauth.NewCodexAuthWithProxyURL(nil, auth.ProxyURL)
|
||||
tokenData, errRefresh := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)
|
||||
if errRefresh != nil {
|
||||
return "", false, errRefresh
|
||||
}
|
||||
if strings.TrimSpace(tokenData.AccessToken) == "" {
|
||||
return "", false, fmt.Errorf("refresh response did not include access_token")
|
||||
}
|
||||
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["id_token"] = tokenData.IDToken
|
||||
auth.Metadata["access_token"] = tokenData.AccessToken
|
||||
if tokenData.RefreshToken != "" {
|
||||
auth.Metadata["refresh_token"] = tokenData.RefreshToken
|
||||
}
|
||||
if tokenData.AccountID != "" {
|
||||
auth.Metadata["account_id"] = tokenData.AccountID
|
||||
}
|
||||
if tokenData.Email != "" {
|
||||
auth.Metadata["email"] = tokenData.Email
|
||||
}
|
||||
auth.Metadata["expired"] = tokenData.Expire
|
||||
auth.Metadata["type"] = "codex"
|
||||
auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
if _, errSave := store.Save(ctx, auth); errSave != nil {
|
||||
return "", false, fmt.Errorf("failed to save refreshed auth: %w", errSave)
|
||||
}
|
||||
|
||||
return tokenData.AccessToken, true, nil
|
||||
}
|
||||
|
||||
func fetchModels(ctx context.Context, auth *coreauth.Auth, accessToken, clientVersion string) ([]byte, int, error) {
|
||||
modelsURL, errURL := codexModelsURL(clientVersion)
|
||||
if errURL != nil {
|
||||
return nil, 0, errURL
|
||||
}
|
||||
|
||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
|
||||
if errReq != nil {
|
||||
return nil, 0, errReq
|
||||
}
|
||||
httpReq.Close = true
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
httpReq.Header.Set("Originator", defaultCodexOriginator)
|
||||
httpReq.Header.Set("User-Agent", defaultCodexUserAgent)
|
||||
if accountID := metaStringValue(auth.Metadata, "account_id"); accountID != "" {
|
||||
httpReq.Header.Set("Chatgpt-Account-Id", accountID)
|
||||
}
|
||||
if auth != nil {
|
||||
util.ApplyCustomHeadersFromAttrs(httpReq, auth.Attributes)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if auth != nil {
|
||||
if transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {
|
||||
httpClient.Transport = transport
|
||||
}
|
||||
}
|
||||
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
return nil, 0, errDo
|
||||
}
|
||||
|
||||
bodyBytes, errRead := io.ReadAll(httpResp.Body)
|
||||
if errClose := httpResp.Body.Close(); errClose != nil && errRead == nil {
|
||||
errRead = errClose
|
||||
}
|
||||
if errRead != nil {
|
||||
return nil, 0, errRead
|
||||
}
|
||||
|
||||
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||
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
|
||||
}
|
||||
return bodyBytes, count, nil
|
||||
}
|
||||
|
||||
func codexModelsURL(clientVersion string) (string, error) {
|
||||
u, err := url.Parse(codexModelsBaseURL + codexModelsPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(clientVersion) != "" {
|
||||
q := u.Query()
|
||||
q.Set("client_version", strings.TrimSpace(clientVersion))
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func countModels(raw []byte) (int, error) {
|
||||
var payload struct {
|
||||
Models []json.RawMessage `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse response JSON: %w", err)
|
||||
}
|
||||
// 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 0, fmt.Errorf("response JSON does not contain models array")
|
||||
}
|
||||
return len(payload.Models), nil
|
||||
}
|
||||
|
||||
func prettyJSON(raw []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf.WriteByte('\n')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func metaStringValue(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(val)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
48
backend/cmd/fetch_codex_models/main_test.go
Normal file
48
backend/cmd/fetch_codex_models/main_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCodexModelsURL(t *testing.T) {
|
||||
got, err := codexModelsURL(" 0.144.1 ")
|
||||
if err != nil {
|
||||
t.Fatalf("codexModelsURL: %v", err)
|
||||
}
|
||||
want := "https://chatgpt.com/backend-api/codex/models?client_version=0.144.1"
|
||||
if got != want {
|
||||
t.Fatalf("codexModelsURL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountModels(t *testing.T) {
|
||||
count, err := countModels([]byte(`{"models":[{"slug":"a"},{"slug":"b"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("countModels(valid): %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("countModels(valid) = %d, want 2", count)
|
||||
}
|
||||
|
||||
// Upstream dumps may omit CPA catalog-required fields; counting must still work.
|
||||
count, err = countModels([]byte(`{"models":[{"slug":"gpt-5.6-sol"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("countModels(incomplete upstream model): %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("countModels(incomplete upstream model) = %d, want 1", count)
|
||||
}
|
||||
|
||||
count, err = countModels([]byte(`{"models":[]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("countModels(empty): %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("countModels(empty) = %d, want 0", count)
|
||||
}
|
||||
|
||||
if _, err := countModels([]byte(`{"models":`)); err == nil {
|
||||
t.Fatal("countModels(malformed) error = nil, want error")
|
||||
}
|
||||
if _, err := countModels([]byte(`{}`)); err == nil {
|
||||
t.Fatal("countModels(missing models) error = nil, want error")
|
||||
}
|
||||
}
|
||||
832
backend/cmd/server/main.go
Normal file
832
backend/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
// Package main provides the entry point for the CLI Proxy API server.
|
||||
// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
|
||||
// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/cmd"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/safemode"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/store"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/tui"
|
||||
"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"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildDate = "unknown"
|
||||
DefaultConfigPath = ""
|
||||
)
|
||||
|
||||
// init initializes the shared logger setup.
|
||||
func init() {
|
||||
logging.SetupBaseLogger()
|
||||
buildinfo.Version = Version
|
||||
buildinfo.Commit = Commit
|
||||
buildinfo.BuildDate = BuildDate
|
||||
}
|
||||
|
||||
func shouldEnableExampleAPIKeySafeMode(cfg *config.Config, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode bool) bool {
|
||||
if cfg == nil || commandMode || homeMode || cloudConfigMissing {
|
||||
return false
|
||||
}
|
||||
if tuiMode && !standalone {
|
||||
return false
|
||||
}
|
||||
return safemode.HasExampleAPIKeys(cfg.APIKeys)
|
||||
}
|
||||
|
||||
// main is the entry point of the application.
|
||||
// It parses command-line flags, loads configuration, and starts the appropriate
|
||||
// service based on the provided flags (login, codex-login, or server mode).
|
||||
func main() {
|
||||
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
||||
|
||||
// Command-line flags to control the application's behavior.
|
||||
var codexLogin bool
|
||||
var codexDeviceLogin bool
|
||||
var claudeLogin bool
|
||||
var noBrowser bool
|
||||
var oauthCallbackPort int
|
||||
var antigravityLogin bool
|
||||
var kimiLogin bool
|
||||
var xaiLogin bool
|
||||
var vertexImport string
|
||||
var vertexImportPrefix string
|
||||
var configPath string
|
||||
var password string
|
||||
var homeJWT string
|
||||
var homeDisableClusterDiscovery bool
|
||||
var tuiMode bool
|
||||
var standalone bool
|
||||
var localModel bool
|
||||
|
||||
// Define command-line flags for different operation modes.
|
||||
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
|
||||
flag.BoolVar(&codexDeviceLogin, "codex-device-login", false, "Login to Codex using device code flow")
|
||||
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
|
||||
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
||||
flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
|
||||
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
||||
flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
|
||||
flag.BoolVar(&xaiLogin, "xai-login", false, "Login to xAI using OAuth")
|
||||
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(&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.BoolVar(&localModel, "local-model", false, "Use embedded models.json and codex_client_models.json only, skip remote model catalog fetching")
|
||||
|
||||
flag.CommandLine.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
_, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
|
||||
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||
if f.Name == "password" {
|
||||
return
|
||||
}
|
||||
s := fmt.Sprintf(" -%s", f.Name)
|
||||
name, unquoteUsage := flag.UnquoteUsage(f)
|
||||
if name != "" {
|
||||
s += " " + name
|
||||
}
|
||||
if len(s) <= 4 {
|
||||
s += " "
|
||||
} else {
|
||||
s += "\n "
|
||||
}
|
||||
if unquoteUsage != "" {
|
||||
s += unquoteUsage
|
||||
}
|
||||
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
|
||||
s += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
}
|
||||
_, _ = fmt.Fprint(out, s+"\n")
|
||||
})
|
||||
}
|
||||
|
||||
pluginHost := pluginhost.New()
|
||||
if bootstrapCfg := loadPluginBootstrapConfig(pluginBootstrapConfigPath(os.Args[1:], DefaultConfigPath)); bootstrapCfg != nil {
|
||||
pluginHost.ApplyConfig(context.Background(), bootstrapCfg)
|
||||
pluginHost.RegisterCommandLineFlags(context.Background(), flag.CommandLine)
|
||||
}
|
||||
|
||||
// Parse the command-line flags.
|
||||
flag.Parse()
|
||||
|
||||
// Core application variables.
|
||||
var err error
|
||||
var cfg *config.Config
|
||||
var isCloudDeploy bool
|
||||
var configLoadedFromHome bool
|
||||
var homeClient *home.Client
|
||||
var homePluginSyncReport homeplugins.SyncReport
|
||||
var homePluginStatusReady bool
|
||||
var (
|
||||
usePostgresStore bool
|
||||
pgStoreDSN string
|
||||
pgStoreSchema string
|
||||
pgStoreLocalPath string
|
||||
pgStoreInst *store.PostgresStore
|
||||
useGitStore bool
|
||||
gitStoreRemoteURL string
|
||||
gitStoreUser string
|
||||
gitStorePassword string
|
||||
gitStoreBranch string
|
||||
gitStoreLocalPath string
|
||||
gitStoreInst *store.GitTokenStore
|
||||
gitStoreRoot string
|
||||
useObjectStore bool
|
||||
objectStoreEndpoint string
|
||||
objectStoreAccess string
|
||||
objectStoreSecret string
|
||||
objectStoreBucket string
|
||||
objectStoreLocalPath string
|
||||
objectStoreInst *store.ObjectTokenStore
|
||||
)
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get working directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Load environment variables from .env if present.
|
||||
if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
|
||||
if !errors.Is(errLoad, os.ErrNotExist) {
|
||||
log.WithError(errLoad).Warn("failed to load .env file")
|
||||
}
|
||||
}
|
||||
|
||||
lookupEnv := func(keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
writableBase := util.WritablePath()
|
||||
|
||||
if strings.TrimSpace(homeJWT) == "" {
|
||||
if v, ok := lookupEnv("HOME_JWT", "home_jwt"); ok {
|
||||
homeJWT = v
|
||||
}
|
||||
}
|
||||
|
||||
if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
|
||||
usePostgresStore = true
|
||||
pgStoreDSN = value
|
||||
}
|
||||
if usePostgresStore {
|
||||
if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
|
||||
pgStoreSchema = value
|
||||
}
|
||||
if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
|
||||
pgStoreLocalPath = value
|
||||
}
|
||||
if pgStoreLocalPath == "" {
|
||||
if writableBase != "" {
|
||||
pgStoreLocalPath = writableBase
|
||||
} else {
|
||||
pgStoreLocalPath = wd
|
||||
}
|
||||
}
|
||||
useGitStore = false
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
|
||||
useGitStore = true
|
||||
gitStoreRemoteURL = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
|
||||
gitStoreUser = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
|
||||
gitStorePassword = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
|
||||
gitStoreLocalPath = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_BRANCH", "gitstore_git_branch"); ok {
|
||||
gitStoreBranch = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
|
||||
useObjectStore = true
|
||||
objectStoreEndpoint = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
|
||||
objectStoreAccess = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
|
||||
objectStoreSecret = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
|
||||
objectStoreBucket = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
|
||||
objectStoreLocalPath = value
|
||||
}
|
||||
|
||||
// Check for cloud deploy mode only on first execution
|
||||
// Read env var name in uppercase: DEPLOY
|
||||
deployEnv := os.Getenv("DEPLOY")
|
||||
if deployEnv == "cloud" {
|
||||
isCloudDeploy = true
|
||||
}
|
||||
|
||||
// Determine and load the configuration file.
|
||||
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
|
||||
var configFilePath string
|
||||
if strings.TrimSpace(homeJWT) != "" {
|
||||
configLoadedFromHome = true
|
||||
ctxHome, cancelHome := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
homeCfg, errHomeCfg := home.ConfigFromJWT(ctxHome, homeJWT)
|
||||
cancelHome()
|
||||
if errHomeCfg != nil {
|
||||
log.Errorf("invalid -home-jwt: %v", errHomeCfg)
|
||||
return
|
||||
}
|
||||
if homeDisableClusterDiscovery {
|
||||
homeCfg.DisableClusterDiscovery = true
|
||||
}
|
||||
homeClient = home.New(homeCfg)
|
||||
defer func() {
|
||||
if homeClient != nil {
|
||||
homeClient.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctxHomeConfig, cancelHomeConfig := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
raw, errGetConfig := homeClient.GetConfig(ctxHomeConfig)
|
||||
cancelHomeConfig()
|
||||
if errGetConfig != nil {
|
||||
log.Errorf("failed to fetch config from home: %v", errGetConfig)
|
||||
return
|
||||
}
|
||||
|
||||
parsed, errParseConfig := config.ParseConfigBytes(raw)
|
||||
if errParseConfig != nil {
|
||||
log.Errorf("failed to parse config payload from home: %v", errParseConfig)
|
||||
return
|
||||
}
|
||||
if parsed == nil {
|
||||
parsed = &config.Config{}
|
||||
}
|
||||
parsed.Home = homeCfg
|
||||
parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config
|
||||
parsed.UsageStatisticsEnabled = true
|
||||
pluginSyncCfg := *parsed
|
||||
parsed.Plugins.StoreAuth = nil
|
||||
var errHomePlugins error
|
||||
platform := homeplugins.CurrentPlatform()
|
||||
if pluginSyncCfg.Plugins.Enabled {
|
||||
ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
installedVersions, errInstalledPlugins := homeplugins.InstalledVersions(&pluginSyncCfg)
|
||||
if errInstalledPlugins != nil {
|
||||
homePluginStatusReady = true
|
||||
errHomePlugins = errInstalledPlugins
|
||||
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errInstalledPlugins)
|
||||
} else {
|
||||
pluginSyncRequest := sdkpluginstore.PluginSyncRequest{
|
||||
SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
|
||||
GOOS: platform.GOOS,
|
||||
GOARCH: platform.GOARCH,
|
||||
InstalledVersions: installedVersions,
|
||||
}
|
||||
pluginSyncResponse, errFetchPlugins := homeClient.GetPluginSync(ctxHomePlugins, pluginSyncRequest)
|
||||
errHomePlugins = errFetchPlugins
|
||||
switch {
|
||||
case errHomePlugins == nil:
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport, errHomePlugins = homeplugins.SyncResolvedWithReport(ctxHomePlugins, &pluginSyncCfg, pluginSyncResponse.Items, pluginSyncResponse.ExpiresAt, pluginSyncRequest.InstalledVersions, pluginHost)
|
||||
case errors.Is(errHomePlugins, home.ErrPluginSyncUnsupported):
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport, errHomePlugins = homeplugins.SyncWithReport(ctxHomePlugins, &pluginSyncCfg, pluginHost)
|
||||
default:
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errHomePlugins)
|
||||
}
|
||||
pluginSyncRequest.Clear()
|
||||
pluginSyncResponse.Clear()
|
||||
}
|
||||
cancelHomePlugins()
|
||||
} else {
|
||||
homePluginStatusReady = true
|
||||
homePluginSyncReport = homeplugins.CompletedSyncReport(platform, nil)
|
||||
}
|
||||
if errHomePlugins != nil {
|
||||
log.Errorf("failed to sync plugins from home: %v", errHomePlugins)
|
||||
}
|
||||
if homePluginStatusReady {
|
||||
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, homeCfg.NodeID, homePluginSyncReport)
|
||||
if errReportPlugins != nil {
|
||||
log.Warnf("failed to report home plugin sync status: %v", errReportPlugins)
|
||||
}
|
||||
}
|
||||
if errHomePlugins != nil {
|
||||
return
|
||||
}
|
||||
cfg = parsed
|
||||
|
||||
// Keep a non-empty config path for downstream components (log paths, management assets, etc),
|
||||
// but do not require the file to exist when loading config from home.
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
configFilePath = configPath
|
||||
} else {
|
||||
configFilePath = filepath.Join(wd, "config.yaml")
|
||||
}
|
||||
|
||||
// Local stores are intentionally disabled when config is loaded from home.
|
||||
usePostgresStore = false
|
||||
useObjectStore = false
|
||||
useGitStore = false
|
||||
} else if usePostgresStore {
|
||||
if pgStoreLocalPath == "" {
|
||||
pgStoreLocalPath = wd
|
||||
}
|
||||
pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
|
||||
DSN: pgStoreDSN,
|
||||
Schema: pgStoreSchema,
|
||||
SpoolDir: pgStoreLocalPath,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Errorf("failed to initialize postgres token store: %v", err)
|
||||
return
|
||||
}
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
||||
cancel()
|
||||
log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap)
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
configFilePath = pgStoreInst.ConfigPath()
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
cfg.AuthDir = pgStoreInst.AuthDir()
|
||||
log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
|
||||
}
|
||||
} else if useObjectStore {
|
||||
if objectStoreLocalPath == "" {
|
||||
if writableBase != "" {
|
||||
objectStoreLocalPath = writableBase
|
||||
} else {
|
||||
objectStoreLocalPath = wd
|
||||
}
|
||||
}
|
||||
objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
|
||||
resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
|
||||
useSSL := true
|
||||
if strings.Contains(resolvedEndpoint, "://") {
|
||||
parsed, errParse := url.Parse(resolvedEndpoint)
|
||||
if errParse != nil {
|
||||
log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http":
|
||||
useSSL = false
|
||||
case "https":
|
||||
useSSL = true
|
||||
default:
|
||||
log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
|
||||
return
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
|
||||
return
|
||||
}
|
||||
resolvedEndpoint = parsed.Host
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
|
||||
}
|
||||
}
|
||||
resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
|
||||
objCfg := store.ObjectStoreConfig{
|
||||
Endpoint: resolvedEndpoint,
|
||||
Bucket: objectStoreBucket,
|
||||
AccessKey: objectStoreAccess,
|
||||
SecretKey: objectStoreSecret,
|
||||
LocalRoot: objectStoreRoot,
|
||||
UseSSL: useSSL,
|
||||
PathStyle: true,
|
||||
}
|
||||
objectStoreInst, err = store.NewObjectTokenStore(objCfg)
|
||||
if err != nil {
|
||||
log.Errorf("failed to initialize object token store: %v", err)
|
||||
return
|
||||
}
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
||||
cancel()
|
||||
log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap)
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
configFilePath = objectStoreInst.ConfigPath()
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
cfg.AuthDir = objectStoreInst.AuthDir()
|
||||
log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
|
||||
}
|
||||
} else if useGitStore {
|
||||
if gitStoreLocalPath == "" {
|
||||
if writableBase != "" {
|
||||
gitStoreLocalPath = writableBase
|
||||
} else {
|
||||
gitStoreLocalPath = wd
|
||||
}
|
||||
}
|
||||
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
|
||||
authDir := filepath.Join(gitStoreRoot, "auths")
|
||||
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword, gitStoreBranch)
|
||||
gitStoreInst.SetBaseDir(authDir)
|
||||
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
|
||||
log.Errorf("failed to prepare git token store: %v", errRepo)
|
||||
return
|
||||
}
|
||||
configFilePath = gitStoreInst.ConfigPath()
|
||||
if configFilePath == "" {
|
||||
configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
|
||||
}
|
||||
if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
if _, errExample := os.Stat(examplePath); errExample != nil {
|
||||
log.Errorf("failed to find template config file: %v", errExample)
|
||||
return
|
||||
}
|
||||
if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
|
||||
log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
|
||||
return
|
||||
}
|
||||
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
|
||||
log.Errorf("failed to commit initial git-backed config: %v", errCommit)
|
||||
return
|
||||
}
|
||||
log.Infof("git-backed config initialized from template: %s", configFilePath)
|
||||
} else if statErr != nil {
|
||||
log.Errorf("failed to inspect git-backed config: %v", statErr)
|
||||
return
|
||||
}
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
cfg.AuthDir = gitStoreInst.AuthDir()
|
||||
log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
|
||||
}
|
||||
} else if configPath != "" {
|
||||
configFilePath = configPath
|
||||
cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
|
||||
} else {
|
||||
wd, err = os.Getwd()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get working directory: %v", err)
|
||||
return
|
||||
}
|
||||
configFilePath = filepath.Join(wd, "config.yaml")
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorf("failed to load config: %v", err)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
|
||||
// In cloud deploy mode, check if we have a valid configuration
|
||||
var configFileExists bool
|
||||
if isCloudDeploy {
|
||||
if configLoadedFromHome && cfg != nil {
|
||||
configFileExists = cfg.Port != 0
|
||||
} else {
|
||||
if info, errStat := os.Stat(configFilePath); errStat != nil {
|
||||
// Don't mislead: API server will not start until configuration is provided.
|
||||
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if info.IsDir() {
|
||||
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if cfg.Port == 0 {
|
||||
// LoadConfigOptional returns empty config when file is empty or invalid.
|
||||
// Config file exists but is empty or invalid; treat as missing config
|
||||
log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
|
||||
configFileExists = false
|
||||
} else {
|
||||
log.Info("Cloud deploy mode: Configuration file detected; starting service")
|
||||
configFileExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
|
||||
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
coreauth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
|
||||
|
||||
if err = logging.ConfigureLogOutput(cfg); err != nil {
|
||||
log.Errorf("failed to configure log output: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
||||
|
||||
// Set the log level based on the configuration.
|
||||
util.SetLogLevel(cfg)
|
||||
|
||||
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
||||
log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir)
|
||||
return
|
||||
} else {
|
||||
cfg.AuthDir = resolvedAuthDir
|
||||
}
|
||||
|
||||
// Create login options to be used in authentication flows.
|
||||
options := &cmd.LoginOptions{
|
||||
NoBrowser: noBrowser,
|
||||
CallbackPort: oauthCallbackPort,
|
||||
}
|
||||
|
||||
commandMode := vertexImport != "" || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || xaiLogin
|
||||
cloudConfigMissing := isCloudDeploy && !configFileExists
|
||||
homeMode := configLoadedFromHome || (cfg != nil && cfg.Home.Enabled)
|
||||
exampleAPIKeySafeMode := shouldEnableExampleAPIKeySafeMode(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode)
|
||||
serverOptions := []api.ServerOption(nil)
|
||||
if exampleAPIKeySafeMode {
|
||||
matches := safemode.ExampleAPIKeys(cfg.APIKeys)
|
||||
log.WithField("api_keys", strings.Join(matches, ",")).Error("unsafe example API key configured; proxy API endpoints disabled until api-keys is updated")
|
||||
serverOptions = append(serverOptions, api.WithExampleAPIKeySafeMode())
|
||||
}
|
||||
|
||||
// Register the shared token store once so all components use the same persistence backend.
|
||||
if usePostgresStore {
|
||||
sdkAuth.RegisterTokenStore(pgStoreInst)
|
||||
} else if useObjectStore {
|
||||
sdkAuth.RegisterTokenStore(objectStoreInst)
|
||||
} else if useGitStore {
|
||||
sdkAuth.RegisterTokenStore(gitStoreInst)
|
||||
} else {
|
||||
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
||||
}
|
||||
|
||||
// Register built-in access providers before constructing services.
|
||||
configaccess.Register(&cfg.SDKConfig)
|
||||
pluginHost.ApplyConfig(context.Background(), cfg)
|
||||
if configLoadedFromHome && homePluginStatusReady {
|
||||
errHomePluginLoad := homeplugins.MarkLoadResults(&homePluginSyncReport, pluginHost)
|
||||
errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, cfg.Home.NodeID, homePluginSyncReport)
|
||||
if errHomePluginLoad != nil {
|
||||
log.Errorf("failed to load home plugins: %v", errHomePluginLoad)
|
||||
}
|
||||
if errReportPlugins != nil {
|
||||
log.Warnf("failed to report home plugin load status: %v", errReportPlugins)
|
||||
}
|
||||
if errHomePluginLoad != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if homeClient != nil {
|
||||
// The bootstrap client is not owned by the runtime service. Close it after
|
||||
// the final startup report so it cannot retain an idle RESP connection.
|
||||
homeClient.Close()
|
||||
homeClient = nil
|
||||
}
|
||||
if pluginHost.HasTriggeredCommandLineFlags() {
|
||||
if exitCode, handled := pluginHost.ExecuteCommandLine(context.Background(), os.Args[0], os.Args[1:], configFilePath, flag.CommandLine); handled {
|
||||
if exitCode != 0 {
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different command modes based on the provided flags.
|
||||
|
||||
if vertexImport != "" {
|
||||
// Handle Vertex service account import
|
||||
cmd.DoVertexImport(cfg, vertexImport, vertexImportPrefix)
|
||||
} else if antigravityLogin {
|
||||
// Handle Antigravity login
|
||||
cmd.DoAntigravityLogin(cfg, options)
|
||||
} else if codexLogin {
|
||||
// Handle Codex login
|
||||
cmd.DoCodexLogin(cfg, options)
|
||||
} else if codexDeviceLogin {
|
||||
// Handle Codex device-code login
|
||||
cmd.DoCodexDeviceLogin(cfg, options)
|
||||
} else if claudeLogin {
|
||||
// Handle Claude login
|
||||
cmd.DoClaudeLogin(cfg, options)
|
||||
} else if kimiLogin {
|
||||
cmd.DoKimiLogin(cfg, options)
|
||||
} else if xaiLogin {
|
||||
cmd.DoXAILogin(cfg, options)
|
||||
} else {
|
||||
// In cloud deploy mode without config file, just wait for shutdown signals
|
||||
if isCloudDeploy && !configFileExists {
|
||||
// No config file available, just wait for shutdown
|
||||
cmd.WaitForCloudDeploy()
|
||||
return
|
||||
}
|
||||
if localModel && (!tuiMode || standalone) {
|
||||
log.Info("Local model mode: using embedded model catalogs, remote model updates disabled")
|
||||
}
|
||||
if tuiMode {
|
||||
if standalone {
|
||||
// Standalone mode: start an embedded local server and connect TUI client to it.
|
||||
misc.StartAntigravityVersionUpdater(context.Background())
|
||||
startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
|
||||
hook := tui.NewLogHook(2000)
|
||||
hook.SetFormatter(&logging.LogFormatter{})
|
||||
log.AddHook(hook)
|
||||
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
origLogOutput := log.StandardLogger().Out
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
devNull, errOpenDevNull := os.Open(os.DevNull)
|
||||
if errOpenDevNull == nil {
|
||||
os.Stdout = devNull
|
||||
os.Stderr = devNull
|
||||
}
|
||||
|
||||
restoreIO := func() {
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
log.SetOutput(origLogOutput)
|
||||
if devNull != nil {
|
||||
_ = devNull.Close()
|
||||
}
|
||||
}
|
||||
|
||||
localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano())
|
||||
if password == "" {
|
||||
password = localMgmtPassword
|
||||
}
|
||||
|
||||
cancel, done := cmd.StartServiceBackgroundWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
|
||||
|
||||
client := tui.NewClient(cfg.Port, password)
|
||||
ready := false
|
||||
backoff := 100 * time.Millisecond
|
||||
for i := 0; i < 30; i++ {
|
||||
if _, errGetConfig := client.GetConfig(); errGetConfig == nil {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
if backoff < time.Second {
|
||||
backoff = time.Duration(float64(backoff) * 1.5)
|
||||
}
|
||||
}
|
||||
|
||||
if !ready {
|
||||
restoreIO()
|
||||
cancel()
|
||||
<-done
|
||||
fmt.Fprintf(os.Stderr, "TUI error: embedded server is not ready\n")
|
||||
return
|
||||
}
|
||||
|
||||
if errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil {
|
||||
restoreIO()
|
||||
fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
|
||||
} else {
|
||||
restoreIO()
|
||||
}
|
||||
|
||||
cancel()
|
||||
<-done
|
||||
} else {
|
||||
// Default TUI mode: pure management client.
|
||||
// The proxy server must already be running.
|
||||
if errRun := tui.Run(cfg.Port, password, nil, os.Stdout); errRun != nil {
|
||||
fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Start the main proxy service
|
||||
misc.StartAntigravityVersionUpdater(context.Background())
|
||||
startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
|
||||
cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modelCatalogUpdaterPlan decides which remote model catalogs should refresh.
|
||||
// Codex client templates still refresh under Home mode because the model list
|
||||
// comes from Home IDs while template metadata stays edge-local.
|
||||
func modelCatalogUpdaterPlan(localModel, homeEnabled bool) (startModels, startCodexClient bool) {
|
||||
if localModel {
|
||||
return false, false
|
||||
}
|
||||
return !homeEnabled, true
|
||||
}
|
||||
|
||||
func startModelCatalogUpdaters(localModel, homeEnabled bool) {
|
||||
startModels, startCodexClient := modelCatalogUpdaterPlan(localModel, homeEnabled)
|
||||
if startCodexClient {
|
||||
registry.StartCodexClientModelsUpdater(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")
|
||||
}
|
||||
}
|
||||
|
||||
func pluginBootstrapConfigPath(args []string, defaultPath string) string {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--":
|
||||
return defaultPluginBootstrapConfigPath(defaultPath)
|
||||
case arg == "-config" || arg == "--config":
|
||||
if i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
return defaultPluginBootstrapConfigPath(defaultPath)
|
||||
case strings.HasPrefix(arg, "-config="):
|
||||
return strings.TrimPrefix(arg, "-config=")
|
||||
case strings.HasPrefix(arg, "--config="):
|
||||
return strings.TrimPrefix(arg, "--config=")
|
||||
}
|
||||
}
|
||||
return defaultPluginBootstrapConfigPath(defaultPath)
|
||||
}
|
||||
|
||||
func defaultPluginBootstrapConfigPath(defaultPath string) string {
|
||||
if strings.TrimSpace(defaultPath) != "" {
|
||||
return defaultPath
|
||||
}
|
||||
wd, errGetwd := os.Getwd()
|
||||
if errGetwd != nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
return filepath.Join(wd, "config.yaml")
|
||||
}
|
||||
|
||||
func loadPluginBootstrapConfig(path string) *config.Config {
|
||||
raw, errReadFile := os.ReadFile(path)
|
||||
if errReadFile != nil {
|
||||
if !errors.Is(errReadFile, os.ErrNotExist) {
|
||||
log.Warnf("failed to read plugin bootstrap config: %v", errReadFile)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.NormalizePluginsConfig()
|
||||
return cfg
|
||||
}
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
cfg := &config.Config{}
|
||||
cfg.NormalizePluginsConfig()
|
||||
return cfg
|
||||
}
|
||||
cfg, errParseConfig := config.ParseConfigBytes(raw)
|
||||
if errParseConfig != nil {
|
||||
log.Warnf("failed to parse plugin bootstrap config: %v", errParseConfig)
|
||||
cfg = &config.Config{}
|
||||
cfg.NormalizePluginsConfig()
|
||||
return cfg
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
137
backend/cmd/server/main_test.go
Normal file
137
backend/cmd/server/main_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestShouldEnableExampleAPIKeySafeMode(t *testing.T) {
|
||||
cfgWithExampleKey := &config.Config{
|
||||
SDKConfig: config.SDKConfig{
|
||||
APIKeys: []string{"real-key", " your-api-key-1 "},
|
||||
},
|
||||
}
|
||||
cfgWithRealKey := &config.Config{
|
||||
SDKConfig: config.SDKConfig{
|
||||
APIKeys: []string{"real-key"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
commandMode bool
|
||||
tuiMode bool
|
||||
standalone bool
|
||||
cloudConfigMissing bool
|
||||
homeMode bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "normal server with example key",
|
||||
cfg: cfgWithExampleKey,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "standalone tui with example key",
|
||||
cfg: cfgWithExampleKey,
|
||||
tuiMode: true,
|
||||
standalone: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "pure tui client is not blocked",
|
||||
cfg: cfgWithExampleKey,
|
||||
tuiMode: true,
|
||||
standalone: false,
|
||||
commandMode: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "one-shot command is not blocked",
|
||||
cfg: cfgWithExampleKey,
|
||||
commandMode: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "home mode is not blocked",
|
||||
cfg: cfgWithExampleKey,
|
||||
homeMode: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "cloud standby without config is not blocked",
|
||||
cfg: cfgWithExampleKey,
|
||||
cloudConfigMissing: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "normal server with real key",
|
||||
cfg: cfgWithRealKey,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil config",
|
||||
cfg: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldEnableExampleAPIKeySafeMode(tt.cfg, tt.commandMode, tt.tuiMode, tt.standalone, tt.cloudConfigMissing, tt.homeMode)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldEnableExampleAPIKeySafeMode() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCatalogUpdaterPlan(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
localModel bool
|
||||
homeEnabled bool
|
||||
wantModels bool
|
||||
wantCodexClient bool
|
||||
}{
|
||||
{
|
||||
name: "normal CPA refreshes both catalogs",
|
||||
localModel: false,
|
||||
homeEnabled: false,
|
||||
wantModels: true,
|
||||
wantCodexClient: true,
|
||||
},
|
||||
{
|
||||
name: "home mode keeps models.json local and refreshes codex templates",
|
||||
localModel: false,
|
||||
homeEnabled: true,
|
||||
wantModels: false,
|
||||
wantCodexClient: true,
|
||||
},
|
||||
{
|
||||
name: "local-model disables both remote catalogs",
|
||||
localModel: true,
|
||||
homeEnabled: false,
|
||||
wantModels: false,
|
||||
wantCodexClient: false,
|
||||
},
|
||||
{
|
||||
name: "local-model disables both remote catalogs even under home",
|
||||
localModel: true,
|
||||
homeEnabled: true,
|
||||
wantModels: false,
|
||||
wantCodexClient: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotModels, gotCodex := modelCatalogUpdaterPlan(tt.localModel, tt.homeEnabled)
|
||||
if gotModels != tt.wantModels || gotCodex != tt.wantCodexClient {
|
||||
t.Fatalf("modelCatalogUpdaterPlan(%v, %v) = (%v, %v), want (%v, %v)",
|
||||
tt.localModel, tt.homeEnabled, gotModels, gotCodex, tt.wantModels, tt.wantCodexClient)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
32
backend/cmd/validate_codex_models/main.go
Normal file
32
backend/cmd/validate_codex_models/main.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Command validate_codex_models validates a Codex client model catalog file.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var inputPath string
|
||||
flag.StringVar(&inputPath, "file", "", "Codex client model catalog JSON file")
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(inputPath) == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --file is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
data, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: read %s: %v\n", inputPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = registry.ValidateCodexClientModelsJSON(data); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid Codex client model catalog %s: %v\n", inputPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Validated Codex client model catalog: %s\n", inputPath)
|
||||
}
|
||||
Loading…
Reference in a new issue