Skip to content
Closed
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
42 changes: 41 additions & 1 deletion backend/internal/pkg/openai/constants.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Package openai provides helpers and types for OpenAI API integration.
package openai

import _ "embed"
import (
_ "embed"
"strings"
)

// Model represents an OpenAI model
type Model struct {
Expand All @@ -16,6 +19,7 @@ type Model struct {
// DefaultModels OpenAI models list
var DefaultModels = []Model{
{ID: "gpt-5.3", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3"},
{ID: "gpt-5.3-codex-spark", Object: "model", Created: 1739404800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex Spark"},
{ID: "gpt-5.3-codex", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex"},
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
{ID: "gpt-5.2-codex", Object: "model", Created: 1733011200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2 Codex"},
Expand All @@ -38,6 +42,42 @@ func DefaultModelIDs() []string {
// DefaultTestModel default model for testing OpenAI accounts
const DefaultTestModel = "gpt-5.1-codex"

// ProOnlyModels 仅 ChatGPT Pro 订阅可用的模型
var ProOnlyModels = map[string]bool{
"gpt-5.3-codex-spark": true,
}

// IsProOnlyModel 检查模型是否仅限 Pro 订阅
func IsProOnlyModel(model string) bool {
normalized := normalizeProOnlyModel(model)
if normalized == "" {
return false
}
if ProOnlyModels[normalized] {
return true
}
for proModel := range ProOnlyModels {
if strings.HasPrefix(normalized, proModel+"-") {
return true
}
}
// Codex CLI 场景常见别名(如 codex-spark / codex-spark-high)。
return normalized == "codex-spark" || strings.HasPrefix(normalized, "codex-spark-")
}

func normalizeProOnlyModel(model string) string {
normalized := strings.TrimSpace(strings.ToLower(model))
if normalized == "" {
return ""
}
if strings.Contains(normalized, "/") {
parts := strings.Split(normalized, "/")
normalized = parts[len(parts)-1]
}
normalized = strings.ReplaceAll(normalized, " ", "-")
return normalized
}

// DefaultInstructions default instructions for non-Codex CLI requests
// Content loaded from instructions.txt at compile time
//
Expand Down
27 changes: 27 additions & 0 deletions backend/internal/pkg/openai/constants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package openai

import "testing"

func TestIsProOnlyModel(t *testing.T) {
tests := []struct {
name string
model string
want bool
}{
{name: "exact pro-only model", model: "gpt-5.3-codex-spark", want: true},
{name: "pro-only model suffix variant", model: "gpt-5.3-codex-spark-high", want: true},
{name: "pro-only model with path prefix", model: "openai/gpt-5.3-codex-spark", want: true},
{name: "codex spark alias", model: "codex-spark", want: true},
{name: "codex spark alias suffix", model: "codex-spark-xhigh", want: true},
{name: "regular model", model: "gpt-5.3-codex", want: false},
{name: "empty model", model: "", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsProOnlyModel(tt.model); got != tt.want {
t.Fatalf("IsProOnlyModel(%q) = %v, want %v", tt.model, got, tt.want)
}
})
}
}
13 changes: 9 additions & 4 deletions backend/internal/pkg/openai/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,13 @@ type IDTokenClaims struct {

// OpenAIAuthClaims represents the OpenAI specific auth claims
type OpenAIAuthClaims struct {
ChatGPTAccountID string `json:"chatgpt_account_id"`
ChatGPTUserID string `json:"chatgpt_user_id"`
UserID string `json:"user_id"`
Organizations []OrganizationClaim `json:"organizations"`
ChatGPTAccountID string `json:"chatgpt_account_id"`
ChatGPTUserID string `json:"chatgpt_user_id"`
ChatGPTPlanType string `json:"chatgpt_plan_type"`
ChatGPTSubscriptionActiveStart any `json:"chatgpt_subscription_active_start"`
ChatGPTSubscriptionActiveUntil any `json:"chatgpt_subscription_active_until"`
UserID string `json:"user_id"`
Organizations []OrganizationClaim `json:"organizations"`
}

// OrganizationClaim represents an organization in the ID Token
Expand Down Expand Up @@ -332,6 +335,7 @@ type UserInfo struct {
Email string
ChatGPTAccountID string
ChatGPTUserID string
ChatGPTPlanType string
UserID string
OrganizationID string
Organizations []OrganizationClaim
Expand All @@ -346,6 +350,7 @@ func (c *IDTokenClaims) GetUserInfo() *UserInfo {
if c.OpenAIAuth != nil {
info.ChatGPTAccountID = c.OpenAIAuth.ChatGPTAccountID
info.ChatGPTUserID = c.OpenAIAuth.ChatGPTUserID
info.ChatGPTPlanType = c.OpenAIAuth.ChatGPTPlanType
info.UserID = c.OpenAIAuth.UserID
info.Organizations = c.OpenAIAuth.Organizations

Expand Down
61 changes: 50 additions & 11 deletions backend/internal/service/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
)

type Account struct {
Expand Down Expand Up @@ -384,22 +385,53 @@ func (a *Account) GetModelMapping() map[string]string {

// IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
// 如果未配置 mapping,返回 true(允许所有模型)
// Pro-only 模型会额外检查账号的 chatgpt_plan_type
func (a *Account) IsModelSupported(requestedModel string) bool {
mapping := a.GetModelMapping()
if len(mapping) == 0 {
return true // 无映射 = 允许所有
mappedModel := requestedModel
supported := true

if len(mapping) > 0 {
supported = false
// 精确匹配
if mapped, exists := mapping[requestedModel]; exists {
supported = true
mappedModel = mapped
} else {
// 通配符匹配
for pattern := range mapping {
if matchWildcard(pattern, requestedModel) {
supported = true
break
}
}
if supported {
mappedModel = matchWildcardMapping(mapping, requestedModel)
}
}
}
// 精确匹配
if _, exists := mapping[requestedModel]; exists {
return true

if !supported {
return false
}
// 通配符匹配
for pattern := range mapping {
if matchWildcard(pattern, requestedModel) {
return true
}

// Pro-only 模型检查:
// 1) requestedModel 本身是 Pro-only
// 2) requestedModel 经过映射后落到 Pro-only
if (openai.IsProOnlyModel(requestedModel) || openai.IsProOnlyModel(mappedModel)) && !a.IsOpenAIProAccount() {
return false
}
return false

return true
}

// IsOpenAIProAccount 检查是否为 OpenAI ChatGPT Pro 订阅账号
func (a *Account) IsOpenAIProAccount() bool {
if !a.IsOpenAIOAuth() {
return false
}
planType := strings.ToLower(a.GetCredential("chatgpt_plan_type"))
return strings.Contains(planType, "pro")
}

// GetMappedModel 获取映射后的模型名(支持通配符,最长优先匹配)
Expand Down Expand Up @@ -664,6 +696,13 @@ func (a *Account) GetOpenAIOrganizationID() string {
return a.GetCredential("organization_id")
}

func (a *Account) GetChatGPTPlanType() string {
if !a.IsOpenAIOAuth() {
return ""
}
return a.GetCredential("chatgpt_plan_type")
}

func (a *Account) GetOpenAITokenExpiresAt() *time.Time {
if !a.IsOpenAIOAuth() {
return nil
Expand Down
96 changes: 96 additions & 0 deletions backend/internal/service/account_wildcard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,102 @@ func TestAccountIsModelSupported(t *testing.T) {
}
}

func TestAccountIsModelSupported_ProOnlyModels(t *testing.T) {
tests := []struct {
name string
account *Account
requestedModel string
expected bool
}{
{
name: "non-pro openai oauth cannot use spark exact model",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
},
requestedModel: "gpt-5.3-codex-spark",
expected: false,
},
{
name: "non-pro openai oauth cannot use spark variant model",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
},
requestedModel: "gpt-5.3-codex-spark-high",
expected: false,
},
{
name: "non-pro openai oauth cannot use codex-spark alias",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
},
requestedModel: "codex-spark",
expected: false,
},
{
name: "non-pro openai oauth cannot bypass via model mapping to spark",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5": "gpt-5.3-codex-spark",
},
},
},
requestedModel: "gpt-5",
expected: false,
},
{
name: "pro openai oauth can use spark exact model",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_plan_type": "chatgptpro",
},
},
requestedModel: "gpt-5.3-codex-spark",
expected: true,
},
{
name: "pro openai oauth can use mapping to spark",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_plan_type": "pro",
"model_mapping": map[string]any{
"gpt-5": "gpt-5.3-codex-spark",
},
},
},
requestedModel: "gpt-5",
expected: true,
},
{
name: "openai api key cannot use spark model",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
},
requestedModel: "gpt-5.3-codex-spark",
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.account.IsModelSupported(tt.requestedModel)
if got != tt.expected {
t.Errorf("IsModelSupported(%q) = %v, want %v", tt.requestedModel, got, tt.expected)
}
})
}
}

func TestAccountGetMappedModel(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading