Compare commits

...

7 Commits

Author SHA1 Message Date
Luis Pater
ae646fba4b refactor(login): disable geminicloudassist API check in required services list
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-10-03 16:53:49 +08:00
Luis Pater
2eef6875e9 feat(auth): improve OpenAI compatibility normalization and API key handling
- Refined trimming and normalization logic for `baseURL` and `apiKey` attributes.
- Updated `Authorization` header logic to omit empty API keys.
- Enhanced compatibility processing by handling empty `api-key-entries`.
- Improved legacy format fallback and added safeguards for empty credentials across executor paths.
2025-10-03 02:38:30 +08:00
Luis Pater
12c09f1a46 feat(runtime): remove previous_response_id from Codex executor request body
- Implemented logic to delete `previous_response_id` property from the request body in Codex executor.
- Applied changes consistently across relevant Codex executor paths.
2025-10-02 12:00:06 +08:00
Luis Pater
4a31f763af feat(management): add proxy support for management asset synchronization
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Introduced `proxyURL` parameter for `EnsureLatestManagementHTML` to enable proxy configuration.
- Refactored HTTP client initialization with new `newHTTPClient` to support proxy-aware requests.
- Updated asset download and fetch logic to utilize the proxy-aware HTTP client.
- Adjusted `server.go` to pass `cfg.ProxyURL` for management asset synchronization calls.
2025-10-01 20:18:26 +08:00
Luis Pater
6629cadb87 refactor(server): remove unused context and managementasset references
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Dropped unused `context` and `managementasset` imports from `main.go`.
- Removed logic for management control panel asset synchronization, including `EnsureLatestManagementHTML`.
- Simplified server initialization by eliminating related control panel functionality.
2025-10-01 03:44:30 +08:00
Luis Pater
41975c9e2b docs(management): document remote-management.disable-control-panel option
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Added explanation for the `remote-management.disable-control-panel` configuration in both `README.md` and `README_CN.md`.
- Clarified its functionality to disable the bundled management UI and return 404 for `/management.html`.
2025-10-01 03:26:06 +08:00
Luis Pater
c589c0d998 feat(management): add support for control panel asset synchronization
- Introduced `EnsureLatestManagementHTML` to sync `management.html` asset from the latest GitHub release.
- Added config option `DisableControlPanel` to toggle control panel functionality.
- Serve management control panel via `/management.html` endpoint, with automatic download and update mechanism.
- Updated `.gitignore` to include `static/*` directory for control panel assets.
2025-10-01 03:18:39 +08:00
10 changed files with 357 additions and 16 deletions

3
.gitignore vendored
View File

@@ -13,4 +13,5 @@ CLAUDE.md
GEMINI.md
*.exe
temp/*
cli-proxy-api
cli-proxy-api
static/*

View File

@@ -72,6 +72,8 @@ A cross-platform desktop GUI client for CLIProxyAPI.
A web-based management center for CLIProxyAPI.
Set `remote-management.disable-control-panel` to `true` if you prefer to host the management UI elsewhere; the server will skip downloading `management.html` and `/management.html` will return 404.
### Authentication
You can authenticate for Gemini, OpenAI, and/or Claude. All can coexist in the same `auth-dir` and will be load balanced.
@@ -277,6 +279,7 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
| `request-retry` | integer | 0 | Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504. |
| `remote-management.allow-remote` | boolean | false | Whether to allow remote (non-localhost) access to the management API. If false, only localhost can access. A management key is still required for localhost. |
| `remote-management.secret-key` | string | "" | Management key. If a plaintext value is provided, it will be hashed on startup using bcrypt and persisted back to the config file. If empty, the entire management API is disabled (404). |
| `remote-management.disable-control-panel` | boolean | false | When true, skip downloading `management.html` and return 404 for `/management.html`, effectively disabling the bundled management UI. |
| `quota-exceeded` | object | {} | Configuration for handling quota exceeded. |
| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. |
| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. |
@@ -326,6 +329,9 @@ remote-management:
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
secret-key: ""
# Disable the bundled management control panel asset download and HTTP route when true.
disable-control-panel: false
# Authentication directory (supports ~ for home directory). If you use Windows, please set the directory like this: `C:/cli-proxy-api/`
auth-dir: "~/.cli-proxy-api"

View File

@@ -85,6 +85,8 @@ CLIProxyAPI 的跨平台桌面图形客户端。
CLIProxyAPI 的基于 Web 的管理中心。
如果希望自行托管管理页面,可在配置中将 `remote-management.disable-control-panel` 设为 `true`,服务器将停止下载 `management.html`,并让 `/management.html` 返回 404。
### 身份验证
您可以分别为 Gemini、OpenAI 和 Claude 进行身份验证,三者可同时存在于同一个 `auth-dir` 中并参与负载均衡。
@@ -289,6 +291,7 @@ console.log(await claudeResponse.json());
| `request-retry` | integer | 0 | 请求重试次数。如果HTTP响应码为403、408、500、502、503或504将会触发重试。 |
| `remote-management.allow-remote` | boolean | false | 是否允许远程非localhost访问管理接口。为false时仅允许本地访问本地访问同样需要管理密钥。 |
| `remote-management.secret-key` | string | "" | 管理密钥。若配置为明文启动时会自动进行bcrypt加密并写回配置文件。若为空管理接口整体不可用404。 |
| `remote-management.disable-control-panel` | boolean | false | 当为 true 时,不再下载 `management.html`,且 `/management.html` 会返回 404从而禁用内置管理界面。 |
| `quota-exceeded` | object | {} | 用于处理配额超限的配置。 |
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 |
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 |
@@ -337,6 +340,9 @@ remote-management:
# 若为空,/v0/management 整体处于 404禁用
secret-key: ""
# 当设为 true 时,不下载管理面板文件,/management.html 将直接返回 404。
disable-control-panel: false
# 身份验证目录(支持 ~ 表示主目录。如果你使用Windows建议设置成`C:/cli-proxy-api/`。
auth-dir: "~/.cli-proxy-api"

View File

@@ -21,6 +21,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"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/usage"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
@@ -225,6 +226,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
// setupRoutes configures the API routes for the server.
// It defines the endpoints and associates them with their respective handlers.
func (s *Server) setupRoutes() {
s.engine.GET("/management.html", s.serveManagementControlPanel)
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
geminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers)
@@ -388,6 +390,34 @@ func (s *Server) setupRoutes() {
}
}
func (s *Server) serveManagementControlPanel(c *gin.Context) {
cfg := s.cfg
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
}
filePath := managementasset.FilePath(s.configFilePath)
if strings.TrimSpace(filePath) == "" {
c.AbortWithStatus(http.StatusNotFound)
return
}
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL)
c.AbortWithStatus(http.StatusNotFound)
return
}
log.WithError(err).Error("failed to stat management control panel asset")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.File(filePath)
}
func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {
if timeout <= 0 || onTimeout == nil {
return
@@ -614,6 +644,11 @@ func (s *Server) UpdateClients(cfg *config.Config) {
s.applyAccessConfig(oldCfg, cfg)
s.cfg = cfg
s.handlers.UpdateClients(&cfg.SDKConfig)
if !cfg.RemoteManagement.DisableControlPanel {
staticDir := managementasset.StaticDir(s.configFilePath)
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
}
if s.mgmt != nil {
s.mgmt.SetConfig(cfg)
s.mgmt.SetAuthManager(s.handlers.AuthManager)

View File

@@ -409,8 +409,8 @@ func showProjectSelectionHelp(email string, projects []interfaces.GCPProjectProj
func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
serviceUsageURL := "https://serviceusage.googleapis.com"
requiredServices := []string{
"geminicloudassist.googleapis.com", // Gemini Cloud Assist API
"cloudaicompanion.googleapis.com", // Gemini for Google Cloud API
// "geminicloudassist.googleapis.com", // Gemini Cloud Assist API
"cloudaicompanion.googleapis.com", // Gemini for Google Cloud API
}
for _, service := range requiredServices {
checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)

View File

@@ -86,6 +86,8 @@ type RemoteManagement struct {
AllowRemote bool `yaml:"allow-remote"`
// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.
SecretKey string `yaml:"secret-key"`
// DisableControlPanel skips serving and syncing the bundled management UI when true.
DisableControlPanel bool `yaml:"disable-control-panel"`
}
// QuotaExceeded defines the behavior when API quota limits are exceeded.

View File

@@ -0,0 +1,256 @@
package managementasset
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus"
)
const (
managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
managementAssetName = "management.html"
httpUserAgent = "CLIProxyAPI-management-updater"
)
// ManagementFileName exposes the control panel asset filename.
const ManagementFileName = managementAssetName
func newHTTPClient(proxyURL string) *http.Client {
client := &http.Client{Timeout: 15 * time.Second}
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}
util.SetProxy(sdkCfg, client)
return client
}
type releaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
}
type releaseResponse struct {
Assets []releaseAsset `json:"assets"`
}
// StaticDir resolves the directory that stores the management control panel asset.
func StaticDir(configFilePath string) string {
configFilePath = strings.TrimSpace(configFilePath)
if configFilePath == "" {
return ""
}
base := filepath.Dir(configFilePath)
return filepath.Join(base, "static")
}
// FilePath resolves the absolute path to the management control panel asset.
func FilePath(configFilePath string) string {
dir := StaticDir(configFilePath)
if dir == "" {
return ""
}
return filepath.Join(dir, ManagementFileName)
}
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
// The function is designed to run in a background goroutine and will never panic.
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) {
if ctx == nil {
ctx = context.Background()
}
staticDir = strings.TrimSpace(staticDir)
if staticDir == "" {
log.Debug("management asset sync skipped: empty static directory")
return
}
if err := os.MkdirAll(staticDir, 0o755); err != nil {
log.WithError(err).Warn("failed to prepare static directory for management asset")
return
}
client := newHTTPClient(proxyURL)
localPath := filepath.Join(staticDir, managementAssetName)
localHash, err := fileSHA256(localPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.WithError(err).Debug("failed to read local management asset hash")
}
localHash = ""
}
asset, remoteHash, err := fetchLatestAsset(ctx, client)
if err != nil {
log.WithError(err).Warn("failed to fetch latest management release information")
return
}
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
log.Debug("management asset is already up to date")
return
}
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
if err != nil {
log.WithError(err).Warn("failed to download management asset")
return
}
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
}
if err = atomicWriteFile(localPath, data); err != nil {
log.WithError(err).Warn("failed to update management asset on disk")
return
}
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
}
func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create release request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", httpUserAgent)
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("execute release request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, "", fmt.Errorf("unexpected release status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var release releaseResponse
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, "", fmt.Errorf("decode release response: %w", err)
}
for i := range release.Assets {
asset := &release.Assets[i]
if strings.EqualFold(asset.Name, managementAssetName) {
remoteHash := parseDigest(asset.Digest)
return asset, remoteHash, nil
}
}
return nil, "", fmt.Errorf("management asset %s not found in latest release", managementAssetName)
}
func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) ([]byte, string, error) {
if strings.TrimSpace(downloadURL) == "" {
return nil, "", fmt.Errorf("empty download url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
req.Header.Set("User-Agent", httpUserAgent)
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("execute download request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("read download body: %w", err)
}
sum := sha256.Sum256(data)
return data, hex.EncodeToString(sum[:]), nil
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() {
_ = file.Close()
}()
h := sha256.New()
if _, err = io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func atomicWriteFile(path string, data []byte) error {
tmpFile, err := os.CreateTemp(filepath.Dir(path), "management-*.html")
if err != nil {
return err
}
tmpName := tmpFile.Name()
defer func() {
_ = tmpFile.Close()
_ = os.Remove(tmpName)
}()
if _, err = tmpFile.Write(data); err != nil {
return err
}
if err = tmpFile.Chmod(0o644); err != nil {
return err
}
if err = tmpFile.Close(); err != nil {
return err
}
if err = os.Rename(tmpName, path); err != nil {
return err
}
return nil
}
func parseDigest(digest string) string {
digest = strings.TrimSpace(digest)
if digest == "" {
return ""
}
if idx := strings.Index(digest, ":"); idx >= 0 {
digest = digest[idx+1:]
}
return strings.ToLower(strings.TrimSpace(digest))
}

View File

@@ -76,6 +76,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
body, _ = sjson.SetBytes(body, "stream", true)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
url := strings.TrimSuffix(baseURL, "/") + "/responses"
recordAPIRequest(ctx, e.cfg, body)
@@ -161,6 +162,8 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
}
body, _ = sjson.DeleteBytes(body, "previous_response_id")
url := strings.TrimSuffix(baseURL, "/") + "/responses"
recordAPIRequest(ctx, e.cfg, body)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))

View File

@@ -40,8 +40,8 @@ func (e *OpenAICompatExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.A
func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" || apiKey == "" {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
if baseURL == "" {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
}
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
@@ -60,7 +60,9 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
return cliproxyexecutor.Response{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -89,8 +91,8 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" || apiKey == "" {
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
if baseURL == "" {
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
}
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
from := opts.SourceFormat
@@ -107,7 +109,9 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
@@ -171,8 +175,8 @@ func (e *OpenAICompatExecutor) resolveCredentials(auth *cliproxyauth.Auth) (base
return "", ""
}
if auth.Attributes != nil {
baseURL = auth.Attributes["base_url"]
apiKey = auth.Attributes["api_key"]
baseURL = strings.TrimSpace(auth.Attributes["base_url"])
apiKey = strings.TrimSpace(auth.Attributes["api_key"])
}
return
}

View File

@@ -532,6 +532,9 @@ func (w *Watcher) reloadConfig() bool {
if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote {
log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote)
}
if oldConfig.RemoteManagement.DisableControlPanel != newConfig.RemoteManagement.DisableControlPanel {
log.Debugf(" remote-management.disable-control-panel: %t -> %t", oldConfig.RemoteManagement.DisableControlPanel, newConfig.RemoteManagement.DisableControlPanel)
}
if oldConfig.LoggingToFile != newConfig.LoggingToFile {
log.Debugf(" logging-to-file: %t -> %t", oldConfig.LoggingToFile, newConfig.LoggingToFile)
}
@@ -796,23 +799,23 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
base := strings.TrimSpace(compat.BaseURL)
// Handle new APIKeyEntries format (preferred)
createdEntries := 0
if len(compat.APIKeyEntries) > 0 {
for j := range compat.APIKeyEntries {
entry := &compat.APIKeyEntries[j]
key := strings.TrimSpace(entry.APIKey)
if key == "" {
continue
}
proxyURL := strings.TrimSpace(entry.ProxyURL)
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
id, token := idGen.next(idKind, key, base, proxyURL)
attrs := map[string]string{
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
"base_url": base,
"api_key": key,
"compat_name": compat.Name,
"provider_key": providerName,
}
if key != "" {
attrs["api_key"] = key
}
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
attrs["models_hash"] = hash
}
@@ -827,6 +830,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
UpdatedAt: now,
}
out = append(out, a)
createdEntries++
}
} else {
// Handle legacy APIKeys format for backward compatibility
@@ -840,10 +844,10 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
attrs := map[string]string{
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
"base_url": base,
"api_key": key,
"compat_name": compat.Name,
"provider_key": providerName,
}
attrs["api_key"] = key
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
attrs["models_hash"] = hash
}
@@ -857,8 +861,32 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
UpdatedAt: now,
}
out = append(out, a)
createdEntries++
}
}
if createdEntries == 0 {
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
id, token := idGen.next(idKind, base)
attrs := map[string]string{
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
"base_url": base,
"compat_name": compat.Name,
"provider_key": providerName,
}
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
attrs["models_hash"] = hash
}
a := &coreauth.Auth{
ID: id,
Provider: providerName,
Label: compat.Name,
Status: coreauth.StatusActive,
Attributes: attrs,
CreatedAt: now,
UpdatedAt: now,
}
out = append(out, a)
}
}
}
// Also synthesize auth entries directly from auth files (for OAuth/file-backed providers)