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
105 changes: 105 additions & 0 deletions internal/api/relay_usage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package api

import (
"context"
"errors"
"net/http"

"cpa-usage-keeper/internal/relayusage"
"github.com/gin-gonic/gin"
)

// RelayUsageProvider 是中转商用量查询服务在 API 层的抽象,便于测试注入。
type RelayUsageProvider interface {
GetUsage(context.Context, relayusage.UsageRequest) (relayusage.UsageResponse, error)
GetPlatformAssignments(context.Context, []string) ([]relayusage.PlatformAssignment, error)
GetPlatformOverrides(context.Context) (map[string]string, error)
UpdatePlatformOverrides(context.Context, relayusage.PlatformOverridesRequest) (map[string]string, error)
}

func registerRelayUsageRoutes(router gin.IRoutes, provider RelayUsageProvider) {
router.POST("/usage/relay-provider/usage", func(c *gin.Context) {
if provider == nil {
writeInternalError(c, "relay usage provider is not configured", nil)
return
}
var request relayusage.UsageRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"})
return
}
if len(request.IdentityIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"})
return
}
response, err := provider.GetUsage(c.Request.Context(), request)
if err != nil {
if errors.Is(err, relayusage.ErrValidation) {
c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"})
return
}
writeInternalError(c, "relay usage lookup failed", err)
return
}
c.JSON(http.StatusOK, response)
})

router.POST("/usage/relay-provider/assignments", func(c *gin.Context) {
if provider == nil {
writeInternalError(c, "relay usage provider is not configured", nil)
return
}
var request relayusage.UsageRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"})
return
}
if len(request.IdentityIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "identity_ids are required"})
return
}
assignments, err := provider.GetPlatformAssignments(c.Request.Context(), request.IdentityIDs)
if err != nil {
writeInternalError(c, "relay platform assignment lookup failed", err)
return
}
c.JSON(http.StatusOK, gin.H{"assignments": assignments})
})

router.GET("/usage/relay-provider/platform-overrides", func(c *gin.Context) {
if provider == nil {
writeInternalError(c, "relay usage provider is not configured", nil)
return
}
overrides, err := provider.GetPlatformOverrides(c.Request.Context())
if err != nil {
writeInternalError(c, "relay platform overrides lookup failed", err)
return
}
if overrides == nil {
overrides = map[string]string{}
}
c.JSON(http.StatusOK, gin.H{"overrides": overrides})
})

router.PUT("/usage/relay-provider/platform-overrides", func(c *gin.Context) {
if provider == nil {
writeInternalError(c, "relay usage provider is not configured", nil)
return
}
var request relayusage.PlatformOverridesRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "overrides are required"})
return
}
overrides, err := provider.UpdatePlatformOverrides(c.Request.Context(), request)
if err != nil {
writeInternalError(c, "relay platform overrides update failed", err)
return
}
if overrides == nil {
overrides = map[string]string{}
}
c.JSON(http.StatusOK, gin.H{"overrides": overrides})
})
}
4 changes: 4 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type StatusRouteConfig struct {
type OptionalProviders struct {
UsageIdentity service.UsageIdentityProvider
Quota QuotaProvider
RelayUsage RelayUsageProvider
CPAAPIKeys service.CPAAPIKeyProvider
AuthFiles service.AuthFilesManagementProvider
RequestLogs service.RequestLogProvider
Expand Down Expand Up @@ -84,13 +85,15 @@ func NewRouter(

var usageIdentityProvider service.UsageIdentityProvider
var quotaProvider QuotaProvider
var relayUsageProvider RelayUsageProvider
var cpaAPIKeyProvider service.CPAAPIKeyProvider
var authFilesProvider service.AuthFilesManagementProvider
var requestLogProvider service.RequestLogProvider
var statusConfig StatusRouteConfig
if len(optionalProviders) > 0 {
usageIdentityProvider = optionalProviders[0].UsageIdentity
quotaProvider = optionalProviders[0].Quota
relayUsageProvider = optionalProviders[0].RelayUsage
cpaAPIKeyProvider = optionalProviders[0].CPAAPIKeys
authFilesProvider = optionalProviders[0].AuthFiles
requestLogProvider = optionalProviders[0].RequestLogs
Expand Down Expand Up @@ -118,6 +121,7 @@ func NewRouter(
registerCPAAPIKeyRoutes(adminProtected, cpaAPIKeyProvider)
registerPricingRoutes(adminProtected, pricingProvider)
registerQuotaRoutes(adminProtected, quotaProvider)
registerRelayUsageRoutes(adminProtected, relayUsageProvider)

keyViewerProtected := apiV1.Group("")
keyViewerProtected.Use(authHandler.apiKeyViewerMiddleware())
Expand Down
5 changes: 5 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"cpa-usage-keeper/internal/logging"
"cpa-usage-keeper/internal/poller"
"cpa-usage-keeper/internal/quota"
"cpa-usage-keeper/internal/relayusage"
"cpa-usage-keeper/internal/repository"
"cpa-usage-keeper/internal/service"
webui "cpa-usage-keeper/web"
Expand Down Expand Up @@ -112,6 +113,9 @@ func NewWithConfig(cfg config.Config) (*App, error) {

cpaClient := cpa.NewClient(cfg.CPABaseURL, cfg.CPAManagementKey, cfg.RequestTimeout, cfg.TLSSkipVerify)
quotaService := quota.NewServiceWithOptions(db, cpaClient, quota.ServiceOptions{RefreshWorkerLimit: cfg.QuotaRefreshWorkerLimit})
// relayUsageService 直连中转商用量接口(GLM/MiniMax/Kimi/DeepSeek),与 OAuth quota 链路隔离。
relayHTTPClient := relayusage.NewHTTPClient(cfg.RequestTimeout, cfg.TLSSkipVerify)
relayUsageService := relayusage.NewService(db, relayusage.NewDefaultAdapterRegistry(relayHTTPClient), relayusage.ServiceOptions{WorkerLimit: cfg.QuotaRefreshWorkerLimit})
// syncService 仍然是 metadata 和 usage 处理共享的业务服务入口。
syncService := service.NewSyncServiceWithOptions(db, service.SyncServiceOptions{
BaseURL: cfg.CPABaseURL,
Expand Down Expand Up @@ -226,6 +230,7 @@ func NewWithConfig(cfg config.Config) (*App, error) {
api.OptionalProviders{
UsageIdentity: usageIdentityService,
Quota: quotaService,
RelayUsage: relayUsageService,
CPAAPIKeys: cpaAPIKeyService,
AuthFiles: authFilesManagementService,
RequestLogs: requestLogService,
Expand Down
22 changes: 22 additions & 0 deletions internal/relayusage/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package relayusage

import (
"context"
)

// Adapter 查询单个中转商平台的用量。Fetch 接收明文 API key(来自 UsageIdentity.LookupKey)
// 与 base-url;多数平台的用量接口 URL 是硬编码的,base-url 仅用于个别平台按区域选择入口。
type Adapter interface {
Platform() string
Fetch(ctx context.Context, apiKey, baseURL string) (RelayUsageResult, error)
}

// NewDefaultAdapterRegistry 构造内置四家中转商的 adapter 集合,key 为平台标识。
func NewDefaultAdapterRegistry(client HTTPDoer) map[string]Adapter {
return map[string]Adapter{
"glm": newGLMAdapter(client),
"minimax": newMiniMaxAdapter(client),
"kimi": newKimiAdapter(client),
"deepseek": newDeepSeekAdapter(client),
}
}
Loading