fix(openai): satisfy Live CI checks

This commit is contained in:
song
2026-07-25 12:51:20 +08:00
parent 988d4b577e
commit db6fbdbf29
16 changed files with 140 additions and 20 deletions
@@ -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
+1 -1
View File
@@ -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
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -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)
@@ -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"),
+1
View File
@@ -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)
+1 -1
View File
@@ -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)
@@ -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()
@@ -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
}
+12
View File
@@ -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<AdminGroup
return getAll(platform)
}
/** 获取当前 Sub2API 服务端的 Live 运行环境能力。 */
export async function getLiveCapability(): Promise<LiveCapability> {
const { data } = await apiClient.get<LiveCapability>('/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,
@@ -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',
@@ -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: '无效请求兜底分组',
+61 -2
View File
@@ -1408,7 +1408,7 @@
}}</label>
<button
type="button"
@click="createForm.allow_live = !createForm.allow_live"
@click="toggleLive('create')"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
createForm.allow_live
@@ -2959,7 +2959,7 @@
}}</label>
<button
type="button"
@click="editForm.allow_live = !editForm.allow_live"
@click="toggleLive('edit')"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
editForm.allow_live
@@ -3562,6 +3562,17 @@
@cancel="showDeleteDialog = false"
/>
<ConfirmDialog
:show="showUnsupportedLiveConfirm"
:title="t('admin.groups.openaiLive.unsupportedTitle')"
:message="t('admin.groups.openaiLive.unsupportedMessage')"
:confirm-text="t('admin.groups.openaiLive.enableAnyway')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmUnsupportedLive"
@cancel="cancelUnsupportedLive"
/>
<!-- Sort Order Modal -->
<BaseDialog
:show="showSortModal"
@@ -4499,6 +4510,15 @@ let abortController: AbortController | null = null;
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteDialog = ref(false);
const pendingLiveForm = ref<"create" | "edit" | null>(null);
const showUnsupportedLiveConfirm = computed(
() => pendingLiveForm.value !== null,
);
const liveCapability = ref<{ supported: boolean; reason?: string } | null>(null);
let liveCapabilityRequest: Promise<{
supported: boolean;
reason?: string;
}> | null = null;
const showSortModal = ref(false);
const submitting = ref(false);
const sortSubmitting = ref(false);
@@ -5149,6 +5169,44 @@ const deleteConfirmMessage = computed(() => {
return t("admin.groups.deleteConfirm", { name: deletingGroup.value.name });
});
const loadLiveCapability = async () => {
if (liveCapability.value) return liveCapability.value;
if (!liveCapabilityRequest) {
liveCapabilityRequest = adminAPI.groups
.getLiveCapability()
.catch(() => ({ supported: false }))
.finally(() => {
liveCapabilityRequest = null;
});
}
liveCapability.value = await liveCapabilityRequest;
return liveCapability.value ?? { supported: false };
};
const toggleLive = async (target: "create" | "edit") => {
const form = target === "create" ? createForm : editForm;
if (form.allow_live) {
form.allow_live = false;
return;
}
const capability = await loadLiveCapability();
if (capability.supported) {
form.allow_live = true;
return;
}
pendingLiveForm.value = target;
};
const confirmUnsupportedLive = () => {
if (pendingLiveForm.value === "create") createForm.allow_live = true;
if (pendingLiveForm.value === "edit") editForm.allow_live = true;
pendingLiveForm.value = null;
};
const cancelUnsupportedLive = () => {
pendingLiveForm.value = null;
};
const loadGroups = async () => {
if (abortController) {
abortController.abort();
@@ -6159,6 +6217,7 @@ const saveSortOrder = async () => {
onMounted(() => {
loadGroups();
void loadLiveCapability();
loadModelsListCandidates("create", 0, createForm.platform);
document.addEventListener("click", handleClickOutside);
});