Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
2b70d1d3
Commit
2b70d1d3
authored
Apr 09, 2026
by
IanShaw027
Browse files
merge upstream main into fix/bug-cleanup-main
parents
b37afd68
00c08c57
Changes
60
Expand all
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/setting_handler.go
View file @
2b70d1d3
...
...
@@ -54,6 +54,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
CustomMenuItems
:
dto
.
ParseUserVisibleMenuItems
(
settings
.
CustomMenuItems
),
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
OIDCOAuthEnabled
:
settings
.
OIDCOAuthEnabled
,
OIDCOAuthProviderName
:
settings
.
OIDCOAuthProviderName
,
BackendModeEnabled
:
settings
.
BackendModeEnabled
,
Version
:
h
.
version
,
})
...
...
backend/internal/pkg/apicompat/types.go
View file @
2b70d1d3
...
...
@@ -28,7 +28,7 @@ type AnthropicRequest struct {
// AnthropicOutputConfig controls output generation parameters.
type
AnthropicOutputConfig
struct
{
Effort
string
`json:"effort,omitempty"`
// "low" | "medium" | "high"
Effort
string
`json:"effort,omitempty"`
// "low" | "medium" | "high"
| "max"
}
// AnthropicThinking configures extended thinking in the Anthropic API.
...
...
@@ -167,7 +167,7 @@ type ResponsesRequest struct {
// ResponsesReasoning configures reasoning effort in the Responses API.
type
ResponsesReasoning
struct
{
Effort
string
`json:"effort"`
// "low" | "medium" | "high"
Effort
string
`json:"effort"`
// "low" | "medium" | "high"
| "xhigh"
Summary
string
`json:"summary,omitempty"`
// "auto" | "concise" | "detailed"
}
...
...
@@ -345,7 +345,7 @@ type ChatCompletionsRequest struct {
StreamOptions
*
ChatStreamOptions
`json:"stream_options,omitempty"`
Tools
[]
ChatTool
`json:"tools,omitempty"`
ToolChoice
json
.
RawMessage
`json:"tool_choice,omitempty"`
ReasoningEffort
string
`json:"reasoning_effort,omitempty"`
// "low" | "medium" | "high"
ReasoningEffort
string
`json:"reasoning_effort,omitempty"`
// "low" | "medium" | "high"
| "xhigh"
ServiceTier
string
`json:"service_tier,omitempty"`
Stop
json
.
RawMessage
`json:"stop,omitempty"`
// string or []string
...
...
backend/internal/repository/group_repo.go
View file @
2b70d1d3
...
...
@@ -61,7 +61,8 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetAllowMessagesDispatch
(
groupIn
.
AllowMessagesDispatch
)
.
SetRequireOauthOnly
(
groupIn
.
RequireOAuthOnly
)
.
SetRequirePrivacySet
(
groupIn
.
RequirePrivacySet
)
.
SetDefaultMappedModel
(
groupIn
.
DefaultMappedModel
)
SetDefaultMappedModel
(
groupIn
.
DefaultMappedModel
)
.
SetMessagesDispatchModelConfig
(
groupIn
.
MessagesDispatchModelConfig
)
// 设置模型路由配置
if
groupIn
.
ModelRouting
!=
nil
{
...
...
@@ -127,7 +128,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetAllowMessagesDispatch
(
groupIn
.
AllowMessagesDispatch
)
.
SetRequireOauthOnly
(
groupIn
.
RequireOAuthOnly
)
.
SetRequirePrivacySet
(
groupIn
.
RequirePrivacySet
)
.
SetDefaultMappedModel
(
groupIn
.
DefaultMappedModel
)
SetDefaultMappedModel
(
groupIn
.
DefaultMappedModel
)
.
SetMessagesDispatchModelConfig
(
groupIn
.
MessagesDispatchModelConfig
)
// 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
if
groupIn
.
DailyLimitUSD
!=
nil
{
...
...
backend/internal/repository/scheduler_cache.go
View file @
2b70d1d3
...
...
@@ -378,6 +378,7 @@ func buildSchedulerMetadataAccount(account service.Account) service.Account {
Platform
:
account
.
Platform
,
Type
:
account
.
Type
,
Concurrency
:
account
.
Concurrency
,
LoadFactor
:
account
.
LoadFactor
,
Priority
:
account
.
Priority
,
RateMultiplier
:
account
.
RateMultiplier
,
Status
:
account
.
Status
,
...
...
backend/internal/server/api_contract_test.go
View file @
2b70d1d3
...
...
@@ -462,6 +462,28 @@ func TestAPIContracts(t *testing.T) {
service
.
SettingKeyTurnstileSiteKey
:
"site-key"
,
service
.
SettingKeyTurnstileSecretKey
:
"secret-key"
,
service
.
SettingKeyOIDCConnectEnabled
:
"false"
,
service
.
SettingKeyOIDCConnectProviderName
:
"OIDC"
,
service
.
SettingKeyOIDCConnectClientID
:
""
,
service
.
SettingKeyOIDCConnectIssuerURL
:
""
,
service
.
SettingKeyOIDCConnectDiscoveryURL
:
""
,
service
.
SettingKeyOIDCConnectAuthorizeURL
:
""
,
service
.
SettingKeyOIDCConnectTokenURL
:
""
,
service
.
SettingKeyOIDCConnectUserInfoURL
:
""
,
service
.
SettingKeyOIDCConnectJWKSURL
:
""
,
service
.
SettingKeyOIDCConnectScopes
:
"openid email profile"
,
service
.
SettingKeyOIDCConnectRedirectURL
:
""
,
service
.
SettingKeyOIDCConnectFrontendRedirectURL
:
"/auth/oidc/callback"
,
service
.
SettingKeyOIDCConnectTokenAuthMethod
:
"client_secret_post"
,
service
.
SettingKeyOIDCConnectUsePKCE
:
"false"
,
service
.
SettingKeyOIDCConnectValidateIDToken
:
"true"
,
service
.
SettingKeyOIDCConnectAllowedSigningAlgs
:
"RS256,ES256,PS256"
,
service
.
SettingKeyOIDCConnectClockSkewSeconds
:
"120"
,
service
.
SettingKeyOIDCConnectRequireEmailVerified
:
"false"
,
service
.
SettingKeyOIDCConnectUserInfoEmailPath
:
""
,
service
.
SettingKeyOIDCConnectUserInfoIDPath
:
""
,
service
.
SettingKeyOIDCConnectUserInfoUsernamePath
:
""
,
service
.
SettingKeySiteName
:
"Sub2API"
,
service
.
SettingKeySiteLogo
:
""
,
service
.
SettingKeySiteSubtitle
:
"Subtitle"
,
...
...
@@ -507,6 +529,28 @@ func TestAPIContracts(t *testing.T) {
"linuxdo_connect_client_id": "",
"linuxdo_connect_client_secret_configured": false,
"linuxdo_connect_redirect_url": "",
"oidc_connect_enabled": false,
"oidc_connect_provider_name": "OIDC",
"oidc_connect_client_id": "",
"oidc_connect_client_secret_configured": false,
"oidc_connect_issuer_url": "",
"oidc_connect_discovery_url": "",
"oidc_connect_authorize_url": "",
"oidc_connect_token_url": "",
"oidc_connect_userinfo_url": "",
"oidc_connect_jwks_url": "",
"oidc_connect_scopes": "openid email profile",
"oidc_connect_redirect_url": "",
"oidc_connect_frontend_redirect_url": "/auth/oidc/callback",
"oidc_connect_token_auth_method": "client_secret_post",
"oidc_connect_use_pkce": false,
"oidc_connect_validate_id_token": true,
"oidc_connect_allowed_signing_algs": "RS256,ES256,PS256",
"oidc_connect_clock_skew_seconds": 120,
"oidc_connect_require_email_verified": false,
"oidc_connect_userinfo_email_path": "",
"oidc_connect_userinfo_id_path": "",
"oidc_connect_userinfo_username_path": "",
"ops_monitoring_enabled": false,
"ops_realtime_monitoring_enabled": true,
"ops_query_mode_default": "auto",
...
...
backend/internal/server/routes/auth.go
View file @
2b70d1d3
...
...
@@ -70,6 +70,14 @@ func RegisterAuthRoutes(
}),
h
.
Auth
.
CompleteLinuxDoOAuthRegistration
,
)
auth
.
GET
(
"/oauth/oidc/start"
,
h
.
Auth
.
OIDCOAuthStart
)
auth
.
GET
(
"/oauth/oidc/callback"
,
h
.
Auth
.
OIDCOAuthCallback
)
auth
.
POST
(
"/oauth/oidc/complete-registration"
,
rateLimiter
.
LimitWithOptions
(
"oauth-oidc-complete"
,
10
,
time
.
Minute
,
middleware
.
RateLimitOptions
{
FailureMode
:
middleware
.
RateLimitFailClose
,
}),
h
.
Auth
.
CompleteOIDCOAuthRegistration
,
)
}
// 公开设置(无需认证)
...
...
backend/internal/service/admin_service.go
View file @
2b70d1d3
...
...
@@ -156,6 +156,7 @@ type CreateGroupInput struct {
DefaultMappedModel
string
RequireOAuthOnly
bool
RequirePrivacySet
bool
MessagesDispatchModelConfig
OpenAIMessagesDispatchModelConfig
// 从指定分组复制账号(创建分组后在同一事务内绑定)
CopyAccountsFromGroupIDs
[]
int64
}
...
...
@@ -190,6 +191,7 @@ type UpdateGroupInput struct {
DefaultMappedModel
*
string
RequireOAuthOnly
*
bool
RequirePrivacySet
*
bool
MessagesDispatchModelConfig
*
OpenAIMessagesDispatchModelConfig
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
CopyAccountsFromGroupIDs
[]
int64
}
...
...
@@ -908,7 +910,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
RequireOAuthOnly
:
input
.
RequireOAuthOnly
,
RequirePrivacySet
:
input
.
RequirePrivacySet
,
DefaultMappedModel
:
input
.
DefaultMappedModel
,
MessagesDispatchModelConfig
:
normalizeOpenAIMessagesDispatchModelConfig
(
input
.
MessagesDispatchModelConfig
),
}
sanitizeGroupMessagesDispatchFields
(
group
)
if
err
:=
s
.
groupRepo
.
Create
(
ctx
,
group
);
err
!=
nil
{
return
nil
,
err
}
...
...
@@ -1135,6 +1139,10 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if
input
.
DefaultMappedModel
!=
nil
{
group
.
DefaultMappedModel
=
*
input
.
DefaultMappedModel
}
if
input
.
MessagesDispatchModelConfig
!=
nil
{
group
.
MessagesDispatchModelConfig
=
normalizeOpenAIMessagesDispatchModelConfig
(
*
input
.
MessagesDispatchModelConfig
)
}
sanitizeGroupMessagesDispatchFields
(
group
)
if
err
:=
s
.
groupRepo
.
Update
(
ctx
,
group
);
err
!=
nil
{
return
nil
,
err
...
...
backend/internal/service/admin_service_group_test.go
View file @
2b70d1d3
...
...
@@ -10,6 +10,11 @@ import (
"github.com/stretchr/testify/require"
)
func
ptrString
[
T
~
string
](
v
T
)
*
string
{
s
:=
string
(
v
)
return
&
s
}
// groupRepoStubForAdmin 用于测试 AdminService 的 GroupRepository Stub
type
groupRepoStubForAdmin
struct
{
created
*
Group
// 记录 Create 调用的参数
...
...
@@ -261,6 +266,116 @@ func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) {
require
.
Nil
(
t
,
repo
.
updated
.
ImagePrice4K
)
}
func
TestAdminService_CreateGroup_NormalizesMessagesDispatchModelConfig
(
t
*
testing
.
T
)
{
repo
:=
&
groupRepoStubForAdmin
{}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"dispatch-group"
,
Description
:
"dispatch config"
,
Platform
:
PlatformOpenAI
,
RateMultiplier
:
1.0
,
MessagesDispatchModelConfig
:
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
" gpt-5.4-high "
,
SonnetMappedModel
:
" gpt-5.3-codex "
,
HaikuMappedModel
:
" gpt-5.4-mini-medium "
,
ExactModelMappings
:
map
[
string
]
string
{
" claude-sonnet-4-5-20250929 "
:
" gpt-5.2-high "
,
},
},
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
created
)
require
.
Equal
(
t
,
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
"gpt-5.4"
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
"gpt-5.4-mini"
,
ExactModelMappings
:
map
[
string
]
string
{
"claude-sonnet-4-5-20250929"
:
"gpt-5.2"
,
},
},
repo
.
created
.
MessagesDispatchModelConfig
)
}
func
TestAdminService_UpdateGroup_NormalizesMessagesDispatchModelConfig
(
t
*
testing
.
T
)
{
existingGroup
:=
&
Group
{
ID
:
1
,
Name
:
"existing-group"
,
Platform
:
PlatformOpenAI
,
Status
:
StatusActive
,
}
repo
:=
&
groupRepoStubForAdmin
{
getByID
:
existingGroup
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
1
,
&
UpdateGroupInput
{
MessagesDispatchModelConfig
:
&
OpenAIMessagesDispatchModelConfig
{
SonnetMappedModel
:
" gpt-5.4-medium "
,
ExactModelMappings
:
map
[
string
]
string
{
" claude-haiku-4-5-20251001 "
:
" gpt-5.4-mini-high "
,
},
},
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
updated
)
require
.
Equal
(
t
,
OpenAIMessagesDispatchModelConfig
{
SonnetMappedModel
:
"gpt-5.4"
,
ExactModelMappings
:
map
[
string
]
string
{
"claude-haiku-4-5-20251001"
:
"gpt-5.4-mini"
,
},
},
repo
.
updated
.
MessagesDispatchModelConfig
)
}
func
TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatform
(
t
*
testing
.
T
)
{
repo
:=
&
groupRepoStubForAdmin
{}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"anthropic-group"
,
Description
:
"non-openai"
,
Platform
:
PlatformAnthropic
,
RateMultiplier
:
1.0
,
AllowMessagesDispatch
:
true
,
DefaultMappedModel
:
"gpt-5.4"
,
MessagesDispatchModelConfig
:
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
"gpt-5.4"
,
},
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
created
)
require
.
False
(
t
,
repo
.
created
.
AllowMessagesDispatch
)
require
.
Empty
(
t
,
repo
.
created
.
DefaultMappedModel
)
require
.
Equal
(
t
,
OpenAIMessagesDispatchModelConfig
{},
repo
.
created
.
MessagesDispatchModelConfig
)
}
func
TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChangesAwayFromOpenAI
(
t
*
testing
.
T
)
{
existingGroup
:=
&
Group
{
ID
:
1
,
Name
:
"existing-openai-group"
,
Platform
:
PlatformOpenAI
,
Status
:
StatusActive
,
AllowMessagesDispatch
:
true
,
DefaultMappedModel
:
"gpt-5.4"
,
MessagesDispatchModelConfig
:
OpenAIMessagesDispatchModelConfig
{
SonnetMappedModel
:
"gpt-5.3-codex"
,
},
}
repo
:=
&
groupRepoStubForAdmin
{
getByID
:
existingGroup
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
1
,
&
UpdateGroupInput
{
Platform
:
PlatformAnthropic
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
updated
)
require
.
Equal
(
t
,
PlatformAnthropic
,
repo
.
updated
.
Platform
)
require
.
False
(
t
,
repo
.
updated
.
AllowMessagesDispatch
)
require
.
Empty
(
t
,
repo
.
updated
.
DefaultMappedModel
)
require
.
Equal
(
t
,
OpenAIMessagesDispatchModelConfig
{},
repo
.
updated
.
MessagesDispatchModelConfig
)
}
func
TestAdminService_ListGroups_WithSearch
(
t
*
testing
.
T
)
{
// 测试:
// 1. search 参数正常传递到 repository 层
...
...
backend/internal/service/auth_service.go
View file @
2b70d1d3
...
...
@@ -833,7 +833,8 @@ func randomHexString(byteLength int) (string, error) {
func
isReservedEmail
(
email
string
)
bool
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
))
return
strings
.
HasSuffix
(
normalized
,
LinuxDoConnectSyntheticEmailDomain
)
return
strings
.
HasSuffix
(
normalized
,
LinuxDoConnectSyntheticEmailDomain
)
||
strings
.
HasSuffix
(
normalized
,
OIDCConnectSyntheticEmailDomain
)
}
// GenerateToken 生成JWT access token
...
...
backend/internal/service/domain_constants.go
View file @
2b70d1d3
...
...
@@ -71,6 +71,9 @@ const (
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
const
LinuxDoConnectSyntheticEmailDomain
=
"@linuxdo-connect.invalid"
// OIDCConnectSyntheticEmailDomain 是 OIDC 用户的合成邮箱后缀(RFC 保留域名)。
const
OIDCConnectSyntheticEmailDomain
=
"@oidc-connect.invalid"
// Setting keys
const
(
// 注册设置
...
...
@@ -105,6 +108,30 @@ const (
SettingKeyLinuxDoConnectClientSecret
=
"linuxdo_connect_client_secret"
SettingKeyLinuxDoConnectRedirectURL
=
"linuxdo_connect_redirect_url"
// Generic OIDC OAuth 登录设置
SettingKeyOIDCConnectEnabled
=
"oidc_connect_enabled"
SettingKeyOIDCConnectProviderName
=
"oidc_connect_provider_name"
SettingKeyOIDCConnectClientID
=
"oidc_connect_client_id"
SettingKeyOIDCConnectClientSecret
=
"oidc_connect_client_secret"
SettingKeyOIDCConnectIssuerURL
=
"oidc_connect_issuer_url"
SettingKeyOIDCConnectDiscoveryURL
=
"oidc_connect_discovery_url"
SettingKeyOIDCConnectAuthorizeURL
=
"oidc_connect_authorize_url"
SettingKeyOIDCConnectTokenURL
=
"oidc_connect_token_url"
SettingKeyOIDCConnectUserInfoURL
=
"oidc_connect_userinfo_url"
SettingKeyOIDCConnectJWKSURL
=
"oidc_connect_jwks_url"
SettingKeyOIDCConnectScopes
=
"oidc_connect_scopes"
SettingKeyOIDCConnectRedirectURL
=
"oidc_connect_redirect_url"
SettingKeyOIDCConnectFrontendRedirectURL
=
"oidc_connect_frontend_redirect_url"
SettingKeyOIDCConnectTokenAuthMethod
=
"oidc_connect_token_auth_method"
SettingKeyOIDCConnectUsePKCE
=
"oidc_connect_use_pkce"
SettingKeyOIDCConnectValidateIDToken
=
"oidc_connect_validate_id_token"
SettingKeyOIDCConnectAllowedSigningAlgs
=
"oidc_connect_allowed_signing_algs"
SettingKeyOIDCConnectClockSkewSeconds
=
"oidc_connect_clock_skew_seconds"
SettingKeyOIDCConnectRequireEmailVerified
=
"oidc_connect_require_email_verified"
SettingKeyOIDCConnectUserInfoEmailPath
=
"oidc_connect_userinfo_email_path"
SettingKeyOIDCConnectUserInfoIDPath
=
"oidc_connect_userinfo_id_path"
SettingKeyOIDCConnectUserInfoUsernamePath
=
"oidc_connect_userinfo_username_path"
// OEM设置
SettingKeySiteName
=
"site_name"
// 网站名称
SettingKeySiteLogo
=
"site_logo"
// 网站Logo (base64)
...
...
backend/internal/service/group.go
View file @
2b70d1d3
...
...
@@ -3,8 +3,12 @@ package service
import
(
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
type
OpenAIMessagesDispatchModelConfig
=
domain
.
OpenAIMessagesDispatchModelConfig
type
Group
struct
{
ID
int64
Name
string
...
...
@@ -53,6 +57,7 @@ type Group struct {
RequireOAuthOnly
bool
// 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
RequirePrivacySet
bool
// 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
DefaultMappedModel
string
MessagesDispatchModelConfig
OpenAIMessagesDispatchModelConfig
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
...
...
backend/internal/service/openai_codex_instructions_template.go
0 → 100644
View file @
2b70d1d3
package
service
import
(
"bytes"
"fmt"
"strings"
"text/template"
)
type
forcedCodexInstructionsTemplateData
struct
{
ExistingInstructions
string
OriginalModel
string
NormalizedModel
string
BillingModel
string
UpstreamModel
string
}
func
applyForcedCodexInstructionsTemplate
(
reqBody
map
[
string
]
any
,
templateText
string
,
data
forcedCodexInstructionsTemplateData
,
)
(
bool
,
error
)
{
rendered
,
err
:=
renderForcedCodexInstructionsTemplate
(
templateText
,
data
)
if
err
!=
nil
{
return
false
,
err
}
if
rendered
==
""
{
return
false
,
nil
}
existing
,
_
:=
reqBody
[
"instructions"
]
.
(
string
)
if
strings
.
TrimSpace
(
existing
)
==
rendered
{
return
false
,
nil
}
reqBody
[
"instructions"
]
=
rendered
return
true
,
nil
}
func
renderForcedCodexInstructionsTemplate
(
templateText
string
,
data
forcedCodexInstructionsTemplateData
,
)
(
string
,
error
)
{
tmpl
,
err
:=
template
.
New
(
"forced_codex_instructions"
)
.
Option
(
"missingkey=zero"
)
.
Parse
(
templateText
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"parse forced codex instructions template: %w"
,
err
)
}
var
buf
bytes
.
Buffer
if
err
:=
tmpl
.
Execute
(
&
buf
,
data
);
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"render forced codex instructions template: %w"
,
err
)
}
return
strings
.
TrimSpace
(
buf
.
String
()),
nil
}
backend/internal/service/openai_compat_model_test.go
View file @
2b70d1d3
...
...
@@ -6,9 +6,12 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
...
...
@@ -127,3 +130,101 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T
t
.
Logf
(
"upstream body: %s"
,
string
(
upstream
.
lastBody
))
t
.
Logf
(
"response body: %s"
,
rec
.
Body
.
String
())
}
func
TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gin
.
SetMode
(
gin
.
TestMode
)
templateDir
:=
t
.
TempDir
()
templatePath
:=
filepath
.
Join
(
templateDir
,
"codex-instructions.md.tmpl"
)
require
.
NoError
(
t
,
os
.
WriteFile
(
templatePath
,
[]
byte
(
"server-prefix
\n\n
{{ .ExistingInstructions }}"
),
0
o644
))
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
body
:=
[]
byte
(
`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
bytes
.
NewReader
(
body
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
upstreamBody
:=
strings
.
Join
([]
string
{
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`
,
""
,
"data: [DONE]"
,
""
,
},
"
\n
"
)
upstream
:=
&
httpUpstreamRecorder
{
resp
:
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"text/event-stream"
},
"x-request-id"
:
[]
string
{
"rid_forced"
}},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
upstreamBody
)),
}}
svc
:=
&
OpenAIGatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
ForcedCodexInstructionsTemplateFile
:
templatePath
,
ForcedCodexInstructionsTemplate
:
"server-prefix
\n\n
{{ .ExistingInstructions }}"
,
}},
httpUpstream
:
upstream
,
}
account
:=
&
Account
{
ID
:
1
,
Name
:
"openai-oauth"
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"access_token"
:
"oauth-token"
,
"chatgpt_account_id"
:
"chatgpt-acc"
,
},
}
result
,
err
:=
svc
.
ForwardAsAnthropic
(
context
.
Background
(),
c
,
account
,
body
,
""
,
"gpt-5.1"
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"server-prefix
\n\n
client-system"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"instructions"
)
.
String
())
}
func
TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateContent
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gin
.
SetMode
(
gin
.
TestMode
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
body
:=
[]
byte
(
`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
bytes
.
NewReader
(
body
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
upstreamBody
:=
strings
.
Join
([]
string
{
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`
,
""
,
"data: [DONE]"
,
""
,
},
"
\n
"
)
upstream
:=
&
httpUpstreamRecorder
{
resp
:
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"text/event-stream"
},
"x-request-id"
:
[]
string
{
"rid_forced_cached"
}},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
upstreamBody
)),
}}
svc
:=
&
OpenAIGatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
ForcedCodexInstructionsTemplateFile
:
"/path/that/should/not/be/read.tmpl"
,
ForcedCodexInstructionsTemplate
:
"cached-prefix
\n\n
{{ .ExistingInstructions }}"
,
}},
httpUpstream
:
upstream
,
}
account
:=
&
Account
{
ID
:
1
,
Name
:
"openai-oauth"
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"access_token"
:
"oauth-token"
,
"chatgpt_account_id"
:
"chatgpt-acc"
,
},
}
result
,
err
:=
svc
.
ForwardAsAnthropic
(
context
.
Background
(),
c
,
account
,
body
,
""
,
"gpt-5.1"
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"cached-prefix
\n\n
client-system"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"instructions"
)
.
String
())
}
backend/internal/service/openai_gateway_messages.go
View file @
2b70d1d3
...
...
@@ -86,6 +86,24 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return
nil
,
fmt
.
Errorf
(
"unmarshal for codex transform: %w"
,
err
)
}
codexResult
:=
applyCodexOAuthTransform
(
reqBody
,
false
,
false
)
forcedTemplateText
:=
""
if
s
.
cfg
!=
nil
{
forcedTemplateText
=
s
.
cfg
.
Gateway
.
ForcedCodexInstructionsTemplate
}
templateUpstreamModel
:=
upstreamModel
if
codexResult
.
NormalizedModel
!=
""
{
templateUpstreamModel
=
codexResult
.
NormalizedModel
}
existingInstructions
,
_
:=
reqBody
[
"instructions"
]
.
(
string
)
if
_
,
err
:=
applyForcedCodexInstructionsTemplate
(
reqBody
,
forcedTemplateText
,
forcedCodexInstructionsTemplateData
{
ExistingInstructions
:
strings
.
TrimSpace
(
existingInstructions
),
OriginalModel
:
originalModel
,
NormalizedModel
:
normalizedModel
,
BillingModel
:
billingModel
,
UpstreamModel
:
templateUpstreamModel
,
});
err
!=
nil
{
return
nil
,
err
}
if
codexResult
.
NormalizedModel
!=
""
{
upstreamModel
=
codexResult
.
NormalizedModel
}
...
...
backend/internal/service/openai_messages_dispatch.go
0 → 100644
View file @
2b70d1d3
package
service
import
"strings"
const
(
defaultOpenAIMessagesDispatchOpusMappedModel
=
"gpt-5.4"
defaultOpenAIMessagesDispatchSonnetMappedModel
=
"gpt-5.3-codex"
defaultOpenAIMessagesDispatchHaikuMappedModel
=
"gpt-5.4-mini"
)
func
normalizeOpenAIMessagesDispatchMappedModel
(
model
string
)
string
{
model
=
NormalizeOpenAICompatRequestedModel
(
strings
.
TrimSpace
(
model
))
return
strings
.
TrimSpace
(
model
)
}
func
normalizeOpenAIMessagesDispatchModelConfig
(
cfg
OpenAIMessagesDispatchModelConfig
)
OpenAIMessagesDispatchModelConfig
{
out
:=
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
normalizeOpenAIMessagesDispatchMappedModel
(
cfg
.
OpusMappedModel
),
SonnetMappedModel
:
normalizeOpenAIMessagesDispatchMappedModel
(
cfg
.
SonnetMappedModel
),
HaikuMappedModel
:
normalizeOpenAIMessagesDispatchMappedModel
(
cfg
.
HaikuMappedModel
),
}
if
len
(
cfg
.
ExactModelMappings
)
>
0
{
out
.
ExactModelMappings
=
make
(
map
[
string
]
string
,
len
(
cfg
.
ExactModelMappings
))
for
requestedModel
,
mappedModel
:=
range
cfg
.
ExactModelMappings
{
requestedModel
=
strings
.
TrimSpace
(
requestedModel
)
mappedModel
=
normalizeOpenAIMessagesDispatchMappedModel
(
mappedModel
)
if
requestedModel
==
""
||
mappedModel
==
""
{
continue
}
out
.
ExactModelMappings
[
requestedModel
]
=
mappedModel
}
if
len
(
out
.
ExactModelMappings
)
==
0
{
out
.
ExactModelMappings
=
nil
}
}
return
out
}
func
claudeMessagesDispatchFamily
(
model
string
)
string
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
model
))
if
!
strings
.
HasPrefix
(
normalized
,
"claude"
)
{
return
""
}
switch
{
case
strings
.
Contains
(
normalized
,
"opus"
)
:
return
"opus"
case
strings
.
Contains
(
normalized
,
"sonnet"
)
:
return
"sonnet"
case
strings
.
Contains
(
normalized
,
"haiku"
)
:
return
"haiku"
default
:
return
""
}
}
func
(
g
*
Group
)
ResolveMessagesDispatchModel
(
requestedModel
string
)
string
{
if
g
==
nil
{
return
""
}
requestedModel
=
strings
.
TrimSpace
(
requestedModel
)
if
requestedModel
==
""
{
return
""
}
cfg
:=
normalizeOpenAIMessagesDispatchModelConfig
(
g
.
MessagesDispatchModelConfig
)
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
ExactModelMappings
[
requestedModel
]);
mappedModel
!=
""
{
return
mappedModel
}
switch
claudeMessagesDispatchFamily
(
requestedModel
)
{
case
"opus"
:
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
OpusMappedModel
);
mappedModel
!=
""
{
return
mappedModel
}
return
defaultOpenAIMessagesDispatchOpusMappedModel
case
"sonnet"
:
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
SonnetMappedModel
);
mappedModel
!=
""
{
return
mappedModel
}
return
defaultOpenAIMessagesDispatchSonnetMappedModel
case
"haiku"
:
if
mappedModel
:=
strings
.
TrimSpace
(
cfg
.
HaikuMappedModel
);
mappedModel
!=
""
{
return
mappedModel
}
return
defaultOpenAIMessagesDispatchHaikuMappedModel
default
:
return
""
}
}
func
sanitizeGroupMessagesDispatchFields
(
g
*
Group
)
{
if
g
==
nil
||
g
.
Platform
==
PlatformOpenAI
{
return
}
g
.
AllowMessagesDispatch
=
false
g
.
DefaultMappedModel
=
""
g
.
MessagesDispatchModelConfig
=
OpenAIMessagesDispatchModelConfig
{}
}
backend/internal/service/openai_messages_dispatch_test.go
0 → 100644
View file @
2b70d1d3
package
service
import
"testing"
import
"github.com/stretchr/testify/require"
func
TestNormalizeOpenAIMessagesDispatchModelConfig
(
t
*
testing
.
T
)
{
t
.
Parallel
()
cfg
:=
normalizeOpenAIMessagesDispatchModelConfig
(
OpenAIMessagesDispatchModelConfig
{
OpusMappedModel
:
" gpt-5.4-high "
,
SonnetMappedModel
:
"gpt-5.3-codex"
,
HaikuMappedModel
:
" gpt-5.4-mini-medium "
,
ExactModelMappings
:
map
[
string
]
string
{
" claude-sonnet-4-5-20250929 "
:
" gpt-5.2-high "
,
""
:
"gpt-5.4"
,
"claude-opus-4-6"
:
" "
,
},
})
require
.
Equal
(
t
,
"gpt-5.4"
,
cfg
.
OpusMappedModel
)
require
.
Equal
(
t
,
"gpt-5.3-codex"
,
cfg
.
SonnetMappedModel
)
require
.
Equal
(
t
,
"gpt-5.4-mini"
,
cfg
.
HaikuMappedModel
)
require
.
Equal
(
t
,
map
[
string
]
string
{
"claude-sonnet-4-5-20250929"
:
"gpt-5.2"
,
},
cfg
.
ExactModelMappings
)
}
backend/internal/service/ops_service.go
View file @
2b70d1d3
...
...
@@ -16,7 +16,7 @@ import (
var
ErrOpsDisabled
=
infraerrors
.
NotFound
(
"OPS_DISABLED"
,
"Ops monitoring is disabled"
)
const
(
opsMaxStoredRequestBodyBytes
=
10
*
1024
opsMaxStoredRequestBodyBytes
=
256
*
1024
opsMaxStoredErrorBodyBytes
=
20
*
1024
)
...
...
backend/internal/service/setting_service.go
View file @
2b70d1d3
This diff is collapsed.
Click to expand it.
backend/internal/service/setting_service_oidc_config_test.go
0 → 100644
View file @
2b70d1d3
//go:build unit
package
service
import
(
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type
settingOIDCRepoStub
struct
{
values
map
[
string
]
string
}
func
(
s
*
settingOIDCRepoStub
)
Get
(
ctx
context
.
Context
,
key
string
)
(
*
Setting
,
error
)
{
panic
(
"unexpected Get call"
)
}
func
(
s
*
settingOIDCRepoStub
)
GetValue
(
ctx
context
.
Context
,
key
string
)
(
string
,
error
)
{
panic
(
"unexpected GetValue call"
)
}
func
(
s
*
settingOIDCRepoStub
)
Set
(
ctx
context
.
Context
,
key
,
value
string
)
error
{
panic
(
"unexpected Set call"
)
}
func
(
s
*
settingOIDCRepoStub
)
GetMultiple
(
ctx
context
.
Context
,
keys
[]
string
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
keys
))
for
_
,
key
:=
range
keys
{
if
value
,
ok
:=
s
.
values
[
key
];
ok
{
out
[
key
]
=
value
}
}
return
out
,
nil
}
func
(
s
*
settingOIDCRepoStub
)
SetMultiple
(
ctx
context
.
Context
,
settings
map
[
string
]
string
)
error
{
panic
(
"unexpected SetMultiple call"
)
}
func
(
s
*
settingOIDCRepoStub
)
GetAll
(
ctx
context
.
Context
)
(
map
[
string
]
string
,
error
)
{
panic
(
"unexpected GetAll call"
)
}
func
(
s
*
settingOIDCRepoStub
)
Delete
(
ctx
context
.
Context
,
key
string
)
error
{
panic
(
"unexpected Delete call"
)
}
func
TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery
(
t
*
testing
.
T
)
{
var
discoveryHits
int
var
baseURL
string
srv
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
if
r
.
URL
.
Path
!=
"/issuer/.well-known/openid-configuration"
{
http
.
NotFound
(
w
,
r
)
return
}
discoveryHits
++
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
fmt
.
Sprintf
(
`{
"authorization_endpoint":"%s/issuer/protocol/openid-connect/auth",
"token_endpoint":"%s/issuer/protocol/openid-connect/token",
"userinfo_endpoint":"%s/issuer/protocol/openid-connect/userinfo",
"jwks_uri":"%s/issuer/protocol/openid-connect/certs"
}`
,
baseURL
,
baseURL
,
baseURL
,
baseURL
)))
}))
defer
srv
.
Close
()
baseURL
=
srv
.
URL
cfg
:=
&
config
.
Config
{
OIDC
:
config
.
OIDCConnectConfig
{
Enabled
:
true
,
ProviderName
:
"OIDC"
,
ClientID
:
"oidc-client"
,
ClientSecret
:
"oidc-secret"
,
IssuerURL
:
srv
.
URL
+
"/issuer"
,
RedirectURL
:
"https://example.com/api/v1/auth/oauth/oidc/callback"
,
FrontendRedirectURL
:
"/auth/oidc/callback"
,
Scopes
:
"openid email profile"
,
TokenAuthMethod
:
"client_secret_post"
,
ValidateIDToken
:
true
,
AllowedSigningAlgs
:
"RS256"
,
ClockSkewSeconds
:
120
,
},
}
repo
:=
&
settingOIDCRepoStub
{
values
:
map
[
string
]
string
{}}
svc
:=
NewSettingService
(
repo
,
cfg
)
got
,
err
:=
svc
.
GetOIDCConnectOAuthConfig
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
1
,
discoveryHits
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/.well-known/openid-configuration"
,
got
.
DiscoveryURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/auth"
,
got
.
AuthorizeURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/token"
,
got
.
TokenURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/userinfo"
,
got
.
UserInfoURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/certs"
,
got
.
JWKSURL
)
}
backend/internal/service/settings_view.go
View file @
2b70d1d3
...
...
@@ -31,6 +31,31 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured
bool
LinuxDoConnectRedirectURL
string
// Generic OIDC OAuth 登录
OIDCConnectEnabled
bool
OIDCConnectProviderName
string
OIDCConnectClientID
string
OIDCConnectClientSecret
string
OIDCConnectClientSecretConfigured
bool
OIDCConnectIssuerURL
string
OIDCConnectDiscoveryURL
string
OIDCConnectAuthorizeURL
string
OIDCConnectTokenURL
string
OIDCConnectUserInfoURL
string
OIDCConnectJWKSURL
string
OIDCConnectScopes
string
OIDCConnectRedirectURL
string
OIDCConnectFrontendRedirectURL
string
OIDCConnectTokenAuthMethod
string
OIDCConnectUsePKCE
bool
OIDCConnectValidateIDToken
bool
OIDCConnectAllowedSigningAlgs
string
OIDCConnectClockSkewSeconds
int
OIDCConnectRequireEmailVerified
bool
OIDCConnectUserInfoEmailPath
string
OIDCConnectUserInfoIDPath
string
OIDCConnectUserInfoUsernamePath
string
SiteName
string
SiteLogo
string
SiteSubtitle
string
...
...
@@ -116,6 +141,8 @@ type PublicSettings struct {
LinuxDoOAuthEnabled
bool
BackendModeEnabled
bool
OIDCOAuthEnabled
bool
OIDCOAuthProviderName
string
Version
string
}
...
...
Prev
1
2
3
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment