Skip to content

Commit f1355ab

Browse files
committed
fix(release): 修复共享模型权限与转发计费并发布 1.2.67
统一共享账号模型目录权限与 Codex 缓存;修复并发槽位释放、OpenCode 会话关联、流式缓存用量和图片计价展示;更新版本及文档站更新日志。 验证:Go unit/vet、前端 1167 项测试与类型检查/构建、文档 lint/链接/build 通过;精确发布文件隐私扫描通过,不含本地配置、凭据或构建产物。
1 parent 722cc0e commit f1355ab

21 files changed

Lines changed: 519 additions & 65 deletions

‎backend/cmd/server/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.66
1+
1.2.67

‎backend/internal/handler/gateway_handler.go‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,9 +1365,27 @@ func (h *GatewayHandler) Models(c *gin.Context) {
13651365
// Keep model discovery scoped to the API key's platform. Without this filter,
13661366
// a group containing mixed-platform accounts can expose mappings that cannot
13671367
// be routed through the current Grok/OpenAI endpoint.
1368-
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
1368+
roomModels, err := h.gatewayService.GetAccountShareModels(c.Request.Context(), apiKey, platform)
1369+
if err != nil {
1370+
if c.Request.Context().Err() != nil {
1371+
return
1372+
}
1373+
if h.handleAccountShareModeAnthropicError(c, err, false) {
1374+
return
1375+
}
1376+
logger.L().Warn("gateway.account_share_models.failed", zap.Error(err))
1377+
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Unable to load account share models")
1378+
return
1379+
}
1380+
var availableModels []string
1381+
if roomModels != nil {
1382+
c.Header("Cache-Control", "private, no-store")
1383+
availableModels = roomModels.Models
1384+
} else {
1385+
availableModels = h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
1386+
}
13691387

1370-
if len(availableModels) > 0 {
1388+
if roomModels != nil || len(availableModels) > 0 {
13711389
if platform == service.PlatformGrok {
13721390
writeGrokModelsList(c, availableModels)
13731391
return

‎backend/internal/handler/openai_codex_models_handler.go‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/gin-gonic/gin"
77

88
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
9+
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
910
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
1011
"github.com/Wei-Shaw/sub2api/internal/service"
1112
)
@@ -25,6 +26,45 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
2526
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups")
2627
return
2728
}
29+
roomModels, err := h.gatewayService.GetAccountShareModels(c.Request.Context(), apiKey)
30+
if err != nil {
31+
if c.Request.Context().Err() != nil {
32+
return
33+
}
34+
if h.handleAccountShareModeSelectionError(c, err, false) {
35+
return
36+
}
37+
logger.LegacyPrintf("handler.openai_codex_models", "resolve account share models failed: %v", err)
38+
h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "Unable to load account share models")
39+
return
40+
}
41+
if roomModels != nil {
42+
c.Header("Cache-Control", "private, no-cache")
43+
// The client ETag belongs to the filtered response, not the upstream
44+
// manifest. Fetch a body before applying the current room permissions.
45+
manifest := &service.CodexModelsManifest{Body: []byte(`{"models":[]}`)}
46+
if len(roomModels.Models) > 0 {
47+
manifest, err = h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), roomModels.Account, c.Query("client_version"), "")
48+
}
49+
if c.Request.Context().Err() != nil {
50+
return
51+
}
52+
if err != nil {
53+
h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
54+
return
55+
}
56+
manifest, err = service.FilterAccountShareCodexModelsManifest(manifest, roomModels.Models, c.GetHeader("If-None-Match"))
57+
if c.Request.Context().Err() != nil {
58+
return
59+
}
60+
if err != nil {
61+
logger.LegacyPrintf("handler.openai_codex_models", "filter account share models manifest failed: %v", err)
62+
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Unable to load account share Codex models")
63+
return
64+
}
65+
writeCodexModelsManifest(c, manifest)
66+
return
67+
}
2868

2969
maxAccountSwitches := h.maxAccountSwitches
3070
if maxAccountSwitches <= 0 {
@@ -67,14 +107,18 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
67107
return
68108
}
69109

70-
if manifest.ETag != "" {
71-
c.Header("ETag", manifest.ETag)
72-
}
73-
if manifest.NotModified {
74-
c.Status(http.StatusNotModified)
75-
return
76-
}
77-
c.Data(http.StatusOK, "application/json", manifest.Body)
110+
writeCodexModelsManifest(c, manifest)
111+
return
112+
}
113+
}
114+
115+
func writeCodexModelsManifest(c *gin.Context, manifest *service.CodexModelsManifest) {
116+
if manifest.ETag != "" {
117+
c.Header("ETag", manifest.ETag)
118+
}
119+
if manifest.NotModified {
120+
c.Status(http.StatusNotModified)
78121
return
79122
}
123+
c.Data(http.StatusOK, "application/json", manifest.Body)
80124
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"sort"
8+
"strings"
9+
)
10+
11+
// AccountShareModels describes the current key's bound account and permitted
12+
// model IDs. A non-nil result with no models must remain an empty model list.
13+
type AccountShareModels struct {
14+
Account *Account
15+
Models []string
16+
}
17+
18+
// GetAccountShareModels bypasses the ordinary group model cache: mode groups
19+
// contain accounts from multiple rooms, while permissions belong to each key.
20+
func (s *GatewayService) GetAccountShareModels(ctx context.Context, apiKey *APIKey, platform string) (*AccountShareModels, error) {
21+
return s.accountShareModeService.modelsForRequest(ctx, apiKey, platform, s.channelService)
22+
}
23+
24+
func (s *OpenAIGatewayService) GetAccountShareModels(ctx context.Context, apiKey *APIKey) (*AccountShareModels, error) {
25+
return s.accountShareModeService.modelsForRequest(ctx, apiKey, PlatformOpenAI, s.channelService)
26+
}
27+
28+
func (s *AccountShareModeService) modelsForRequest(ctx context.Context, apiKey *APIKey, platform string, channels *ChannelService) (*AccountShareModels, error) {
29+
if s == nil || apiKey == nil || apiKey.GroupID == nil {
30+
return nil, nil
31+
}
32+
isMode, err := s.IsModeGroupChecked(ctx, *apiKey.GroupID)
33+
if err != nil {
34+
return nil, fmt.Errorf("check account share model group: %w", err)
35+
}
36+
if !isMode {
37+
return nil, nil
38+
}
39+
if apiKey.UserID <= 0 || apiKey.ID <= 0 {
40+
return nil, ErrAccountShareModeGroupUnbound
41+
}
42+
// This read applies the member's effective terms without activating queued
43+
// rooms, renewing paid seats, touching idle time, or rebinding accounts.
44+
membership, listing, err := s.repo.GetActiveMembershipForRequest(ctx, apiKey.UserID, apiKey.ID, *apiKey.GroupID)
45+
if errors.Is(err, ErrAccountShareListingNotFound) {
46+
return nil, ErrAccountShareModeGroupUnbound
47+
}
48+
if err != nil {
49+
return nil, fmt.Errorf("read account share model binding: %w", err)
50+
}
51+
if membership == nil || listing == nil || membership.AccountID <= 0 {
52+
return nil, ErrAccountShareModeGroupUnbound
53+
}
54+
if s.accountRepo == nil {
55+
return nil, ErrServiceUnavailable
56+
}
57+
account, err := s.accountRepo.GetByID(ctx, membership.AccountID)
58+
if err != nil {
59+
return nil, fmt.Errorf("read account share model account: %w", err)
60+
}
61+
if account == nil || account.ID != membership.AccountID || account.Platform != platform || listing.Platform != platform {
62+
return nil, ErrAccountShareModeSelection
63+
}
64+
if s.pricedModelCatalog == nil {
65+
return nil, ErrOwnedAccountModelCatalogUnavailable
66+
}
67+
candidates, err := s.pricedModelCatalog.ListSelectablePricedModelIDs(ctx, PricedModelQuery{Platform: platform})
68+
if err != nil {
69+
return nil, ErrOwnedAccountModelCatalogUnavailable.WithCause(err)
70+
}
71+
// Wildcard pricing can authorize concrete room/account models that the
72+
// selectable catalog cannot enumerate. Never expose the patterns themselves.
73+
candidates = append(append([]string(nil), candidates...), listing.AllowedModels...)
74+
for model := range account.GetModelMapping() {
75+
candidates = append(candidates, model)
76+
}
77+
result := &AccountShareModels{Account: account, Models: make([]string, 0, len(candidates))}
78+
for _, model := range normalizeAllowedModels(candidates) {
79+
if strings.ContainsAny(model, "*?") {
80+
continue
81+
}
82+
selectionModel, err := accountShareDiscoverySelectionModel(ctx, channels, *apiKey.GroupID, account, model)
83+
if err != nil {
84+
return nil, ErrOwnedAccountModelCatalogUnavailable.WithCause(err)
85+
}
86+
if !accountShareListingAllowsModel(listing, selectionModel) || !account.IsModelSupported(selectionModel) {
87+
continue
88+
}
89+
priced, err := s.pricedModelCatalog.IsModelPriced(ctx, PricedModelQuery{Platform: platform}, model)
90+
if err != nil {
91+
return nil, ErrOwnedAccountModelCatalogUnavailable.WithCause(err)
92+
}
93+
if !priced {
94+
continue
95+
}
96+
if selectionModel != model {
97+
priced, err = s.pricedModelCatalog.IsModelPriced(ctx, PricedModelQuery{Platform: platform}, selectionModel)
98+
if err != nil {
99+
return nil, ErrOwnedAccountModelCatalogUnavailable.WithCause(err)
100+
}
101+
if !priced {
102+
continue
103+
}
104+
}
105+
restricted, err := accountShareDiscoveryModelRestricted(ctx, channels, *apiKey.GroupID, account, selectionModel)
106+
if err != nil {
107+
return nil, ErrOwnedAccountModelCatalogUnavailable.WithCause(err)
108+
}
109+
if !restricted {
110+
result.Models = append(result.Models, model)
111+
}
112+
}
113+
sort.Strings(result.Models)
114+
return result, nil
115+
}
116+
117+
// OpenAI-compatible handlers apply channel mapping before selecting the room
118+
// account. Anthropic's native handler checks the original requested model.
119+
func accountShareDiscoverySelectionModel(ctx context.Context, channels *ChannelService, groupID int64, account *Account, model string) (string, error) {
120+
if channels == nil || !account.IsOpenAICompatible() {
121+
return model, nil
122+
}
123+
mapping, err := channels.ResolveChannelMappingChecked(ctx, groupID, model)
124+
if err != nil {
125+
return "", err
126+
}
127+
if mapping.Mapped && strings.TrimSpace(mapping.MappedModel) != "" {
128+
return strings.TrimSpace(mapping.MappedModel), nil
129+
}
130+
return model, nil
131+
}
132+
133+
// Use the same billing-model basis as dispatch, including account mappings
134+
// when the channel restricts upstream models. An unbound channel adds no limit.
135+
func accountShareDiscoveryModelRestricted(ctx context.Context, channels *ChannelService, groupID int64, account *Account, model string) (bool, error) {
136+
if channels == nil {
137+
return false, nil
138+
}
139+
mapping, err := channels.ResolveChannelMappingChecked(ctx, groupID, model)
140+
if err != nil {
141+
return false, err
142+
}
143+
billingModel := billingModelForRestriction(mapping.BillingModelSource, model, mapping.MappedModel)
144+
if mapping.BillingModelSource == BillingModelSourceUpstream {
145+
if account.IsOpenAICompatible() {
146+
billingModel = resolveOpenAIAccountUpstreamModelForRequest(account, model, false)
147+
} else {
148+
billingModel = resolveAccountUpstreamModel(account, model)
149+
}
150+
}
151+
if billingModel == "" {
152+
return false, nil
153+
}
154+
return channels.IsModelRestrictedChecked(ctx, groupID, billingModel)
155+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package service
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"encoding/json"
7+
"fmt"
8+
"strings"
9+
)
10+
11+
// FilterAccountShareCodexModelsManifest limits a complete upstream manifest to
12+
// concrete model IDs available to the current account-share key. Unknown fields
13+
// are preserved, and the client ETag describes the filtered representation.
14+
func FilterAccountShareCodexModelsManifest(manifest *CodexModelsManifest, allowedModels []string, ifNoneMatch string) (*CodexModelsManifest, error) {
15+
if manifest == nil || manifest.NotModified || len(manifest.Body) == 0 {
16+
return nil, fmt.Errorf("filter account share Codex models manifest: complete upstream body is required")
17+
}
18+
var envelope map[string]json.RawMessage
19+
if err := json.Unmarshal(manifest.Body, &envelope); err != nil {
20+
return nil, fmt.Errorf("decode account share Codex models manifest: %w", err)
21+
}
22+
modelsJSON := bytes.TrimSpace(envelope["models"])
23+
if len(modelsJSON) == 0 || modelsJSON[0] != '[' {
24+
return nil, fmt.Errorf("account share Codex models manifest must contain a models array")
25+
}
26+
var entries []json.RawMessage
27+
if err := json.Unmarshal(modelsJSON, &entries); err != nil {
28+
return nil, fmt.Errorf("decode account share Codex models array: %w", err)
29+
}
30+
allowed := make(map[string]struct{}, len(allowedModels))
31+
for _, model := range allowedModels {
32+
allowed[model] = struct{}{}
33+
}
34+
filtered := make([]json.RawMessage, 0, len(entries))
35+
for index, entry := range entries {
36+
var model struct {
37+
Slug string `json:"slug"`
38+
}
39+
if err := json.Unmarshal(entry, &model); err != nil {
40+
return nil, fmt.Errorf("decode account share Codex model at index %d: %w", index, err)
41+
}
42+
if strings.TrimSpace(model.Slug) == "" {
43+
return nil, fmt.Errorf("account share Codex model at index %d must contain a nonempty slug", index)
44+
}
45+
if _, ok := allowed[model.Slug]; ok {
46+
filtered = append(filtered, entry)
47+
}
48+
}
49+
var err error
50+
envelope["models"], err = json.Marshal(filtered)
51+
if err != nil {
52+
return nil, fmt.Errorf("encode filtered account share Codex models: %w", err)
53+
}
54+
body, err := json.Marshal(envelope)
55+
if err != nil {
56+
return nil, fmt.Errorf("encode filtered account share Codex models manifest: %w", err)
57+
}
58+
filteredManifest := &CodexModelsManifest{
59+
Body: body,
60+
ETag: fmt.Sprintf(`"%x"`, sha256.Sum256(body)),
61+
}
62+
return codexModelsManifestForClient(filteredManifest, ifNoneMatch), nil
63+
}

‎backend/internal/service/chatcompletions_anthropic_bridge.go‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,6 +1155,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
11551155
upstreamReq.Header.Set("user-agent", userAgent)
11561156
}
11571157
account.ApplyHeaderOverrides(upstreamReq.Header)
1158+
ensureOpencodeSessionHeader(c, account, upstreamReq, body)
11581159
proxyURL := ""
11591160
if account.Proxy != nil {
11601161
proxyURL = account.Proxy.URL()
@@ -1230,6 +1231,7 @@ func (s *OpenAIGatewayService) bufferDirectChatCompletionsAsAnthropic(
12301231
usage := OpenAIUsage{}
12311232
if parsed := openAIUsageFromChatCompletionsUsage(string(respBody)); parsed != nil {
12321233
usage = *parsed
1234+
chatResp.Usage = normalizedChatUsage(chatResp.Usage, usage)
12331235
}
12341236
result := updateOpenAIForwardResultBillingState(ctx, openAIForwardResultSnapshot{
12351237
requestID: requestID,
@@ -1328,13 +1330,11 @@ func (s *OpenAIGatewayService) streamDirectChatCompletionsAsAnthropic(
13281330
}
13291331
if payload == "[DONE]" {
13301332
sawDone = true
1331-
continue
1333+
break
13321334
}
13331335
observer.ObserveOpenAI([]byte(payload), strings.TrimSpace(gjson.Get(payload, "type").String()))
13341336
billingUsageObservation.observePayload([]byte(payload))
1335-
if parsed := extractOpenAIChatStreamUsage(payload); parsed != nil {
1336-
usage = *parsed
1337-
}
1337+
usageUpdated := mergeOpenAIChatUsage(&usage, payload)
13381338
var chunk apicompat.ChatCompletionsChunk
13391339
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
13401340
logger.L().Warn("openai messages chat fallback: failed to parse stream chunk", zap.Error(err), zap.String("request_id", requestID))
@@ -1344,6 +1344,11 @@ func (s *OpenAIGatewayService) streamDirectChatCompletionsAsAnthropic(
13441344
milliseconds := int(time.Since(startTime).Milliseconds())
13451345
firstTokenMs = &milliseconds
13461346
}
1347+
if usageUpdated {
1348+
chunk.Usage = normalizedChatUsage(chunk.Usage, usage)
1349+
} else {
1350+
chunk.Usage = nil
1351+
}
13471352
emit(ChatCompletionsChunkToAnthropicEvents(&chunk, state))
13481353
}
13491354
if err := scanner.Err(); err != nil {

0 commit comments

Comments
 (0)