Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ Channel icons identify their respective upstream providers. All trademarks belon

For problems or feature ideas, open a [GitHub Issue](https://github.com/tbphp/gpt-load/issues). Report security vulnerabilities through the process in [SECURITY.md](SECURITY.md).

For community chat and usage discussion, join the [Telegram group](https://t.me/+GHpy5SwEllg3MTUx).

If GPT-Load is useful to you, a star is appreciated.

## Sponsors and support
Expand Down
2 changes: 2 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ GPT-Load 自身负责凭据存储、账号选择、调度、重试、健康、

遇到问题或有功能建议,欢迎提交 [GitHub Issue](https://github.com/tbphp/gpt-load/issues)。安全漏洞请按 [SECURITY.md](SECURITY.md) 的流程报告。

社区交流与使用讨论可加入 [Telegram 群组](https://t.me/+GHpy5SwEllg3MTUx)。

如果 GPT-Load 对你有帮助,欢迎点个 Star。

## 赞助与支持
Expand Down
2 changes: 2 additions & 0 deletions README_JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ GPT-Load の一部機能は以下のプロジェクトを基盤としていま

問題や機能の提案は [GitHub Issue](https://github.com/tbphp/gpt-load/issues) へお寄せください。セキュリティ脆弱性は [SECURITY.md](SECURITY.md) の手順に従って報告してください。

コミュニティでの交流や利用方法の相談は、[Telegram グループ](https://t.me/+GHpy5SwEllg3MTUx) にご参加ください。

GPT-Load が役に立ったら、Star をいただけると嬉しいです。

## スポンサーと支援
Expand Down
11 changes: 10 additions & 1 deletion internal/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"gpt-load/internal/pricing"
"gpt-load/internal/provideradapter"
"gpt-load/internal/ratelimit"
"gpt-load/internal/releasecheck"
"gpt-load/internal/requestlog"
"gpt-load/internal/state"
stateloader "gpt-load/internal/state/loader"
Expand Down Expand Up @@ -110,6 +111,8 @@ func BuildContainer() (*dig.Container, error) {
control.NewRuntime,
func(runtime *control.Runtime) app.ControlRuntime { return runtime },
httpclient.NewHTTPClientManager,
releasecheck.NewClient,
releasecheck.NewChecker,
func(manager *httpclient.HTTPClientManager) *catalog.Client {
return catalog.NewClient(manager, "")
},
Expand Down Expand Up @@ -162,7 +165,13 @@ func BuildContainer() (*dig.Container, error) {
control.NewCatalogSyncCoordinator,
func(service *control.Service) app.StartupBootstrap { return service },
func(service *control.Service) app.StartupRecovery { return service },
control.NewServer,
func(
cfg *config.Config,
service *control.Service,
checker *releasecheck.Checker,
) *control.Server {
return control.NewServerWithReleaseUpdateChecker(cfg, service, checker)
},
newHTTPRegistry,
func(
db *gorm.DB,
Expand Down
1 change: 1 addition & 0 deletions internal/control/http_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ func (s *Server) HTTPModule() httproute.Module {
s.handleUpdateSettings,
),
controlRoute("control.system.info", http.MethodGet, "/system/info", s.handleSystemInfo),
controlRoute("control.system.update", http.MethodGet, "/system/update", s.handleSystemUpdate),
controlRoute(
"control.groups.list",
http.MethodGet,
Expand Down
18 changes: 18 additions & 0 deletions internal/control/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,15 @@ import (
"gpt-load/internal/platform/i18n"
"gpt-load/internal/platform/response"
"gpt-load/internal/platform/utils"
"gpt-load/internal/releasecheck"
subscriptionruntime "gpt-load/internal/subscription/runtime"
)

// ReleaseUpdateChecker is the control-plane on-demand view of the public release checker.
type ReleaseUpdateChecker interface {
Check(context.Context) (*releasecheck.Update, error)
}

type Server struct {
authDigest [sha256.Size]byte
service *Service
Expand All @@ -35,6 +41,7 @@ type Server struct {
authFailureEvents *utils.RateLimitedEventCounter
startedAt time.Time
now func() time.Time
releaseChecker ReleaseUpdateChecker
}

const maxControlJSONBodyBytes int64 = 32 << 20
Expand All @@ -61,6 +68,17 @@ func NewServer(cfg *config.Config, service *Service) *Server {
}
}

// NewServerWithReleaseUpdateChecker wires the on-demand public update checker.
func NewServerWithReleaseUpdateChecker(
cfg *config.Config,
service *Service,
releaseChecker ReleaseUpdateChecker,
) *Server {
server := NewServer(cfg, service)
server.releaseChecker = releaseChecker
return server
}

func (s *Server) handleGetSettings(c *gin.Context) {
result, err := s.service.GetSettings(c.Request.Context())
if err != nil {
Expand Down
6 changes: 5 additions & 1 deletion internal/control/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (
"gpt-load/internal/storage/models"
)

func TestServerHomeRoutesUseExactManagementContracts(t *testing.T) {
func TestServerHomeAndSystemUpdateRoutesUseExactManagementContracts(t *testing.T) {
fixture := newServiceFixture(t)
module := NewServer(
&config.Config{AuthKey: "test-auth-key"},
Expand All @@ -48,6 +48,10 @@ func TestServerHomeRoutesUseExactManagementContracts(t *testing.T) {
method: http.MethodGet,
path: "/home/statistics",
},
"control.system.update": {
method: http.MethodGet,
path: "/system/update",
},
}
seen := make(map[string]int, len(want))
for _, route := range module.Routes {
Expand Down
49 changes: 49 additions & 0 deletions internal/control/system_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package control

import (
"fmt"

"github.com/gin-gonic/gin"

app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/response"
)

type systemUpdateResponse struct {
Update *releaseUpdateResponse `json:"update"`
}

type releaseUpdateResponse struct {
Version string `json:"version"`
ReleaseURL string `json:"release_url"`
PublishedAtMS int64 `json:"published_at_ms"`
}

func (s *Server) handleSystemUpdate(c *gin.Context) {
if c.Request.URL.RawQuery != "" || c.Request.URL.ForceQuery {
writeServiceError(c, "system_update", app_errors.ErrBadRequest)
return
}
if s.releaseChecker == nil {
writeServiceError(c, "system_update", app_errors.ErrInternalServer)
return
}
available, err := s.releaseChecker.Check(c.Request.Context())
if err != nil {
writeServiceError(
c,
"system_update",
fmt.Errorf("check public release update: %w: %w", err, app_errors.ErrBadGateway),
)
return
}
var update *releaseUpdateResponse
if available != nil {
update = &releaseUpdateResponse{
Version: available.Version,
ReleaseURL: available.ReleaseURL,
PublishedAtMS: available.PublishedAtMS,
}
}
response.SuccessI18n(c, "common.success", systemUpdateResponse{Update: update})
}
158 changes: 158 additions & 0 deletions internal/control/system_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package control

import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"

"github.com/gin-gonic/gin"

"gpt-load/internal/platform/config"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/releasecheck"
)

type recordingReleaseUpdateChecker struct {
update *releasecheck.Update
err error
calls int
}

func (checker *recordingReleaseUpdateChecker) Check(context.Context) (*releasecheck.Update, error) {
checker.calls++
if checker.update == nil {
return nil, checker.err
}
result := *checker.update
return &result, checker.err
}

func TestSystemUpdateHTTPChecksOnDemandWithoutAffectingHome(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
checker := &recordingReleaseUpdateChecker{update: &releasecheck.Update{
Version: "v2.0.0-beta.9",
ReleaseURL: "https://github.com/tbphp/gpt-load/releases/tag/v2.0.0-beta.9",
PublishedAtMS: time.Date(2026, time.August, 20, 0, 0, 0, 0, time.UTC).UnixMilli(),
}}
server := NewServerWithReleaseUpdateChecker(
&config.Config{AuthKey: "test-auth-key"},
fixture.service,
checker,
)
engine := gin.New()
server.RegisterRoutes(engine)

home := performHomeRequest(engine, "/api/home", "test-auth-key")
if home.Code != http.StatusOK {
t.Fatalf("GET /api/home = %d %s", home.Code, home.Body.String())
}
var homeEnvelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(home.Body.Bytes(), &homeEnvelope); err != nil {
t.Fatalf("decode home response: %v", err)
}
if _, exists := homeEnvelope.Data["update"]; exists || checker.calls != 0 {
t.Fatalf("home update field/check calls = %v/%d, want absent/0", exists, checker.calls)
}

recorder := performHomeRequest(engine, "/api/system/update", "test-auth-key")
if recorder.Code != http.StatusOK {
t.Fatalf("GET /api/system/update = %d %s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Data systemUpdateResponse `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode update response: %v", err)
}
want := releaseUpdateResponse{
Version: checker.update.Version,
ReleaseURL: checker.update.ReleaseURL,
PublishedAtMS: checker.update.PublishedAtMS,
}
if envelope.Data.Update == nil || *envelope.Data.Update != want || checker.calls != 1 {
t.Fatalf("update/calls = %#v/%d, want %#v/1", envelope.Data.Update, checker.calls, want)
}
assertManagementWireObject(t, envelope.Data, []string{"update"})
assertManagementWireObject(t, *envelope.Data.Update, []string{
"version", "release_url", "published_at_ms",
})
}

func TestSystemUpdateHTTPReturnsNullForSuccessfulNoUpdate(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
checker := &recordingReleaseUpdateChecker{}
server := NewServerWithReleaseUpdateChecker(
&config.Config{AuthKey: "test-auth-key"},
fixture.service,
checker,
)
engine := gin.New()
server.RegisterRoutes(engine)

recorder := performHomeRequest(engine, "/api/system/update", "test-auth-key")
if recorder.Code != http.StatusOK || checker.calls != 1 {
t.Fatalf("GET /api/system/update = %d %s, calls=%d", recorder.Code, recorder.Body.String(), checker.calls)
}
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if raw, exists := envelope.Data["update"]; !exists || string(raw) != "null" {
t.Fatalf("update = %s, exists=%v, want null/true", raw, exists)
}
}

func TestSystemUpdateHTTPHidesUpstreamFailureBehindBadGateway(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
checker := &recordingReleaseUpdateChecker{err: errors.New("private upstream detail")}
server := NewServerWithReleaseUpdateChecker(
&config.Config{AuthKey: "test-auth-key"},
fixture.service,
checker,
)
engine := gin.New()
server.RegisterRoutes(engine)

recorder := performHomeRequest(engine, "/api/system/update", "test-auth-key")
if recorder.Code != http.StatusBadGateway || checker.calls != 1 ||
!strings.Contains(recorder.Body.String(), `"code":"`+app_errors.ErrBadGateway.Code+`"`) ||
strings.Contains(recorder.Body.String(), "private upstream detail") {
t.Fatalf("GET /api/system/update = %d %s, calls=%d", recorder.Code, recorder.Body.String(), checker.calls)
}
}

func TestSystemUpdateHTTPRejectsAccessKeyAndQueryBeforeCheck(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
accessKey, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "read only"})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
checker := &recordingReleaseUpdateChecker{}
server := NewServerWithReleaseUpdateChecker(
&config.Config{AuthKey: "test-auth-key"},
fixture.service,
checker,
)
engine := gin.New()
server.RegisterRoutes(engine)

accessKeyResponse := performHomeRequest(engine, "/api/system/update", accessKey.Key)
assertHomeHTTPError(t, accessKeyResponse, http.StatusForbidden, app_errors.ErrForbidden.Code)
queryResponse := performHomeRequest(engine, "/api/system/update?refresh=1", "test-auth-key")
assertHomeHTTPError(t, queryResponse, http.StatusBadRequest, app_errors.ErrBadRequest.Code)
if checker.calls != 0 {
t.Fatalf("rejected request check calls = %d, want 0", checker.calls)
}
}
Loading