diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index b913187638..3646f5ab3a 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -13,6 +13,7 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -25,6 +26,16 @@ type GroupHandler struct { groupCapacityService *service.GroupCapacityService } +// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。 +func (h *GroupHandler) GetLiveCapability(c *gin.Context) { + err := liveattestation.NewProvider().Check(c.Request.Context()) + result := gin.H{"supported": err == nil} + if err != nil { + result["reason"] = err.Error() + } + response.Success(c, result) +} + type optionalLimitField struct { set bool value *float64 diff --git a/backend/internal/handler/openai_live.go b/backend/internal/handler/openai_live.go index b2bad0e0ea..0e4a5af45b 100644 --- a/backend/internal/handler/openai_live.go +++ b/backend/internal/handler/openai_live.go @@ -220,7 +220,7 @@ func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) { if err != nil { return } - defer downstream.CloseNow() + defer func() { _ = downstream.CloseNow() }() if err := h.gatewayService.ProxyLiveSideband(c.Request.Context(), record, downstream); err != nil { _ = downstream.Close(coderws.StatusInternalError, "live sideband closed") return diff --git a/backend/internal/platform/liveattestation/attestation.go b/backend/internal/platform/liveattestation/attestation.go index bfcb61dc36..305e33d368 100644 --- a/backend/internal/platform/liveattestation/attestation.go +++ b/backend/internal/platform/liveattestation/attestation.go @@ -6,11 +6,12 @@ import ( ) var ( - ErrUnsupportedPlatform = errors.New("Live attestation is only supported when Sub2API runs on macOS; Windows support is not implemented yet") - ErrChatGPTAppMissing = errors.New("Live attestation requires the official ChatGPT app on the Sub2API server") + ErrUnsupportedPlatform = errors.New("live attestation is only supported when Sub2API runs on macOS; Windows support is not implemented yet") + ErrChatGPTAppMissing = errors.New("live attestation requires the official ChatGPT app on the Sub2API server") ) // Provider 在发起 Live 请求前生成 ChatGPT DeviceCheck attestation。 type Provider interface { + Check(ctx context.Context) error Generate(ctx context.Context) (string, error) } diff --git a/backend/internal/platform/liveattestation/attestation_darwin.go b/backend/internal/platform/liveattestation/attestation_darwin.go index 9c0c560b89..c0d3779bfa 100644 --- a/backend/internal/platform/liveattestation/attestation_darwin.go +++ b/backend/internal/platform/liveattestation/attestation_darwin.go @@ -58,13 +58,20 @@ func NewProvider() Provider { } } -func (p *darwinProvider) Generate(ctx context.Context) (string, error) { +func (p *darwinProvider) Check(ctx context.Context) error { + checkCtx, cancel := context.WithTimeout(ctx, attestationTimeout) + defer cancel() + _, _, _, err := p.resolveRuntime(checkCtx) + return err +} + +func (p *darwinProvider) resolveRuntime(ctx context.Context) (string, string, string, error) { if runtime.GOARCH != "arm64" { - return "", errors.New("Live attestation currently requires Apple Silicon; Intel macOS is not supported") + return "", "", "", errors.New("live attestation currently requires Apple Silicon; Intel macOS is not supported") } appPath, err := p.findApplication() if err != nil { - return "", err + return "", "", "", err } resourcesPath := filepath.Join(appPath, "Contents", "Resources") nodePath := filepath.Join(resourcesPath, "cua_node", "bin", "node") @@ -74,13 +81,20 @@ func (p *darwinProvider) Generate(ctx context.Context) (string, error) { modulePath: "DeviceCheck native module", } { if info, statErr := os.Stat(filePath); statErr != nil || info.IsDir() { - return "", fmt.Errorf("%w: ChatGPT app is missing its %s", ErrChatGPTAppMissing, label) + return "", "", "", fmt.Errorf("%w: ChatGPT app is missing its %s", ErrChatGPTAppMissing, label) } } + bundleID, err := readBundleIdentifier(ctx, appPath) + if err != nil { + return "", "", "", err + } + return nodePath, modulePath, bundleID, nil +} +func (p *darwinProvider) Generate(ctx context.Context) (string, error) { runCtx, cancel := context.WithTimeout(ctx, attestationTimeout) defer cancel() - bundleID, err := readBundleIdentifier(runCtx, appPath) + nodePath, modulePath, bundleID, err := p.resolveRuntime(runCtx) if err != nil { return "", err } diff --git a/backend/internal/platform/liveattestation/attestation_unsupported.go b/backend/internal/platform/liveattestation/attestation_unsupported.go index 176561200a..4eebfc45d3 100644 --- a/backend/internal/platform/liveattestation/attestation_unsupported.go +++ b/backend/internal/platform/liveattestation/attestation_unsupported.go @@ -10,6 +10,10 @@ func NewProvider() Provider { return unsupportedProvider{} } +func (unsupportedProvider) Check(context.Context) error { + return ErrUnsupportedPlatform +} + func (unsupportedProvider) Generate(context.Context) (string, error) { return "", ErrUnsupportedPlatform } diff --git a/backend/internal/platform/liveattestation/attestation_unsupported_test.go b/backend/internal/platform/liveattestation/attestation_unsupported_test.go index 24577a2635..8ac8fc1082 100644 --- a/backend/internal/platform/liveattestation/attestation_unsupported_test.go +++ b/backend/internal/platform/liveattestation/attestation_unsupported_test.go @@ -9,7 +9,11 @@ import ( ) func TestUnsupportedProviderReturnsExplicitPlatformError(t *testing.T) { - _, err := NewProvider().Generate(context.Background()) + provider := NewProvider() + if err := provider.Check(context.Background()); !errors.Is(err, ErrUnsupportedPlatform) { + t.Fatalf("Check() error = %v, want ErrUnsupportedPlatform", err) + } + _, err := provider.Generate(context.Background()) if !errors.Is(err, ErrUnsupportedPlatform) { t.Fatalf("Generate() error = %v, want ErrUnsupportedPlatform", err) } diff --git a/backend/internal/repository/concurrency_cache_live_test.go b/backend/internal/repository/concurrency_cache_live_test.go index 6dbebe50c4..6f8a143165 100644 --- a/backend/internal/repository/concurrency_cache_live_test.go +++ b/backend/internal/repository/concurrency_cache_live_test.go @@ -15,7 +15,8 @@ func TestLiveLeaseReplacesRegularSlotsAndCountsTowardLimits(t *testing.T) { redisServer := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) regular := NewConcurrencyCache(client, 15, 900) - live := regular.(service.LiveConcurrencyCache) + live, ok := regular.(service.LiveConcurrencyCache) + require.True(t, ok) ctx := context.Background() accountAcquired, err := regular.AcquireAccountSlot(ctx, 10, 1, "regular-account") @@ -54,7 +55,8 @@ func TestLiveLeaseExpiresWithoutRefresh(t *testing.T) { redisServer := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) regular := NewConcurrencyCache(client, 15, 900) - live := regular.(service.LiveConcurrencyCache) + live, ok := regular.(service.LiveConcurrencyCache) + require.True(t, ok) ctx := context.Background() acquired, err := live.AcquireLiveLease(ctx, 10, 1, 20, 1, 30, "expired-live", false) diff --git a/backend/internal/repository/gateway_cache_live_test.go b/backend/internal/repository/gateway_cache_live_test.go index 7d14bfdd73..aa4327501f 100644 --- a/backend/internal/repository/gateway_cache_live_test.go +++ b/backend/internal/repository/gateway_cache_live_test.go @@ -14,8 +14,10 @@ import ( func TestGatewayCacheLiveCallIdentityAndController(t *testing.T) { redisServer := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) - cache := NewGatewayCache(client).(service.LiveCallStore) - otherInstance := NewGatewayCache(client).(service.LiveCallStore) + cache, ok := NewGatewayCache(client).(service.LiveCallStore) + require.True(t, ok) + otherInstance, ok := NewGatewayCache(client).(service.LiveCallStore) + require.True(t, ok) record := &service.LiveCallRecord{ CallID: "call_secret", CallHash: HashLiveCallID("call_secret"), diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ddb78a4160..a2117fe312 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -317,6 +317,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups.GET("/all", h.Admin.Group.GetAll) groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary) groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary) + groups.GET("/live-capability", h.Admin.Group.GetLiveCapability) groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder) groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates) groups.GET("/:id/composite-routes", h.Admin.Group.ListCompositeRoutes) diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 1d4df6b36b..3687c0c445 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -511,7 +511,7 @@ func (s *OpenAIGatewayService) ProxyLiveSideband( go s.observeLiveCall(record.CallHash) return err } - defer upstream.Close() + defer func() { _ = upstream.Close() }() downstream.SetReadLimit(openAIWSMessageReadLimitBytes) proxyCtx, cancel := context.WithCancel(ctx) diff --git a/backend/internal/service/openai_live_lifecycle_test.go b/backend/internal/service/openai_live_lifecycle_test.go index 40eee7885e..59571cf3ce 100644 --- a/backend/internal/service/openai_live_lifecycle_test.go +++ b/backend/internal/service/openai_live_lifecycle_test.go @@ -371,7 +371,7 @@ func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { proxyResult <- err return } - defer downstream.CloseNow() + defer func() { _ = downstream.CloseNow() }() proxyResult <- service.ProxyLiveSideband(request.Context(), record, downstream) })) defer server.Close() @@ -382,7 +382,7 @@ func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { nil, ) require.NoError(t, err) - defer client.CloseNow() + defer func() { _ = client.CloseNow() }() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() diff --git a/backend/internal/service/openai_live_test.go b/backend/internal/service/openai_live_test.go index 60cf2f0d77..81cff13b8c 100644 --- a/backend/internal/service/openai_live_test.go +++ b/backend/internal/service/openai_live_test.go @@ -26,6 +26,10 @@ type liveAttestationStub struct { err error } +func (s liveAttestationStub) Check(context.Context) error { + return s.err +} + func (s liveAttestationStub) Generate(context.Context) (string, error) { return s.header, s.err } diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 0e69be4994..3828bf9d71 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -16,6 +16,11 @@ import type { PaginatedResponse } from '@/types' +export interface LiveCapability { + supported: boolean + reason?: string +} + /** * List all groups with pagination * @param page - Page number (default: 1) @@ -81,6 +86,12 @@ export async function getByPlatform(platform: GroupPlatform): Promise { + const { data } = await apiClient.get('/admin/groups/live-capability') + return data +} + /** * Get group by ID * @param id - Group ID @@ -467,6 +478,7 @@ export const groupsAPI = { getAll, getByPlatform, getAllIncludingInactive, + getLiveCapability, getById, getModelsListCandidates, create, diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 1859847345..1dfcec2988 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1098,7 +1098,10 @@ export default { openaiLive: { title: 'OpenAI Live', allow: 'Allow Live access', - hint: 'When enabled, API keys in this OpenAI group can create and control Live voice sessions. Disabled by default. The Sub2API server must run on macOS with the official ChatGPT app installed; client platforms are unrestricted.' + hint: 'When enabled, API keys in this OpenAI group can create and control Live voice sessions. Disabled by default. The Sub2API server must run on Apple Silicon macOS with the official ChatGPT app installed; client platforms are unrestricted.', + unsupportedTitle: 'Current server does not support Live', + unsupportedMessage: 'This Sub2API server cannot generate the required Live attestation. Live will not work even if enabled. Continue anyway?', + enableAnyway: 'Enable anyway' }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index e1234fb4bc..ac4838105d 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1096,7 +1096,10 @@ export default { openaiLive: { title: 'OpenAI Live', allow: '允许访问 Live', - hint: '启用后,此 OpenAI 分组的 API Key 可以创建并控制 Live 语音会话。默认关闭。运行 Sub2API 的服务端必须是 macOS,并安装官方 ChatGPT App;客户端平台不受限制。' + hint: '启用后,此 OpenAI 分组的 API Key 可以创建并控制 Live 语音会话。默认关闭。运行 Sub2API 的服务端必须是 Apple Silicon Mac,并安装官方 ChatGPT App;客户端平台不受限制。', + unsupportedTitle: '当前服务端不支持 Live', + unsupportedMessage: '当前 Sub2API 服务端无法生成 Live 所需的设备证明,即使开启也不能使用。是否仍然开启?', + enableAnyway: '仍然开启' }, invalidRequestFallback: { title: '无效请求兜底分组', diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index d56e5d4725..260628fd05 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1408,7 +1408,7 @@ }}