feat(openai): 支持 Agent Identity 认证

This commit is contained in:
cat
2026-07-14 17:05:46 +08:00
parent deff3123de
commit 1dab126944
23 changed files with 1399 additions and 64 deletions
@@ -0,0 +1,47 @@
package admin
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestNormalizeCodexImportEntryAcceptsAgentIdentityAuthJSON(t *testing.T) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
privateKeyBase64 := base64.StdEncoding.EncodeToString(der)
item, err := normalizeCodexImportEntry(codexImportEntry{
Index: 1,
Value: map[string]any{
"auth_mode": "agentIdentity",
"agent_identity": map[string]any{
"agent_runtime_id": "runtime-import",
"agent_private_key": privateKeyBase64,
"account_id": "account-import",
"chatgpt_user_id": "user-import",
"email": "agent@example.invalid",
"plan_type": "pro",
"chatgpt_account_is_fedramp": false,
},
},
})
require.NoError(t, err)
require.NotNil(t, item)
require.True(t, item.IsAgentIdentity)
require.Equal(t, service.OpenAIAuthModeAgentIdentity, item.Credentials["auth_mode"])
require.Equal(t, "runtime-import", item.Credentials["agent_runtime_id"])
require.Equal(t, privateKeyBase64, item.Credentials["agent_private_key"])
require.Equal(t, "account-import", item.Credentials["chatgpt_account_id"])
require.Equal(t, "user-import", item.Credentials["chatgpt_user_id"])
require.NotContains(t, item.Credentials, "access_token")
require.NotContains(t, item.Credentials, "refresh_token")
require.NotEmpty(t, item.WarningTexts)
}
@@ -72,20 +72,25 @@ type codexImportEntry struct {
}
type codexImportAccount struct {
Name string
AccessToken string
RefreshToken string
IDToken string
Email string
AccountID string
UserID string
PlanType string
Organization string
Credentials map[string]any
Extra map[string]any
TokenExpiresAt *time.Time
IdentityKeys []string
WarningTexts []string
Name string
AccessToken string
RefreshToken string
IDToken string
Email string
AccountID string
UserID string
PlanType string
Organization string
AgentRuntimeID string
AgentPrivateKey string
AgentTaskID string
AgentFedRAMP bool
IsAgentIdentity bool
Credentials map[string]any
Extra map[string]any
TokenExpiresAt *time.Time
IdentityKeys []string
WarningTexts []string
}
type codexJWTClaims struct {
@@ -492,6 +497,41 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
case string:
item.AccessToken = strings.TrimSpace(raw)
case map[string]any:
if agentIdentity, ok := firstCodexMap(raw, []string{"agent_identity"}, []string{"agentIdentity"}); ok || strings.EqualFold(firstCodexString(raw, []string{"auth_mode"}, []string{"authMode"}), service.OpenAIAuthModeAgentIdentity) {
if !ok {
agentIdentity = raw
}
item.IsAgentIdentity = true
item.AgentRuntimeID = firstCodexString(agentIdentity, []string{"agent_runtime_id"}, []string{"agentRuntimeId"})
item.AgentPrivateKey = firstCodexString(agentIdentity, []string{"agent_private_key"}, []string{"agentPrivateKey"})
item.AgentTaskID = firstCodexString(agentIdentity, []string{"task_id"}, []string{"taskId"})
item.AccountID = firstCodexString(agentIdentity, []string{"account_id"}, []string{"accountId"})
item.UserID = firstCodexString(agentIdentity, []string{"chatgpt_user_id"}, []string{"chatgptUserId"})
item.Email = firstCodexString(agentIdentity, []string{"email"})
item.PlanType = firstCodexString(agentIdentity, []string{"plan_type"}, []string{"planType"})
item.AgentFedRAMP = firstCodexBool(agentIdentity, []string{"chatgpt_account_is_fedramp"}, []string{"chatgptAccountIsFedramp"})
if item.AgentRuntimeID == "" || item.AgentPrivateKey == "" || item.AccountID == "" || item.UserID == "" {
return nil, errors.New("Agent Identity 缺少必要字段")
}
if err := service.ValidateOpenAIAgentIdentityPrivateKey(item.AgentPrivateKey); err != nil {
return nil, errors.New("Agent Identity private key 格式无效")
}
item.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity
item.Credentials["agent_runtime_id"] = item.AgentRuntimeID
item.Credentials["agent_private_key"] = item.AgentPrivateKey
item.Credentials["chatgpt_account_id"] = item.AccountID
item.Credentials["chatgpt_user_id"] = item.UserID
item.Credentials["chatgpt_account_is_fedramp"] = item.AgentFedRAMP
setCodexCredentialIfNotEmpty(item.Credentials, "task_id", item.AgentTaskID)
setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
if item.AgentTaskID == "" {
item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task")
}
item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID, item.UserID, item.Email, item.AgentRuntimeID)
item.Name = buildCodexImportAccountName(item, entry.Index)
return item, nil
}
item.AccessToken = firstCodexString(raw,
[]string{"tokens", "access_token"},
[]string{"tokens", "accessToken"},
@@ -573,6 +613,9 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index)
}
if item.IsAgentIdentity {
return item, nil
}
if item.AccessToken == "" {
return nil, errors.New("缺少 accessToken/access_token")
}
@@ -808,6 +851,9 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
"openai_auth_mode": {},
"token_type": {},
"chatgpt_account_is_fedramp": {},
"agent_runtime_id": {},
"agent_private_key": {},
"task_id": {},
}
out := make(map[string]any, len(input))
for key, value := range input {
@@ -838,6 +884,14 @@ func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refresh
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
}
func buildCodexAgentIdentityKeys(accountID, userID, email, runtimeID string) []string {
keys := buildCodexStoredIdentityKeys(accountID, userID, email, "")
if runtimeID = strings.TrimSpace(runtimeID); runtimeID != "" {
keys = append([]string{"agent:" + runtimeID}, keys...)
}
return keys
}
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
@@ -887,6 +941,10 @@ func (i *codexAccountIndex) Add(account service.Account) {
for _, key := range keys {
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
}
if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" {
key := "agent:" + runtimeID
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
}
}
func (i *codexAccountIndex) remove(accountID int64) {
@@ -1039,6 +1097,38 @@ func firstCodexString(obj map[string]any, paths ...[]string) string {
return ""
}
func firstCodexMap(obj map[string]any, paths ...[]string) (map[string]any, bool) {
for _, path := range paths {
value, ok := codexPathValue(obj, path)
if !ok || value == nil {
continue
}
if mapped, ok := value.(map[string]any); ok {
return mapped, true
}
}
return nil, false
}
func firstCodexBool(obj map[string]any, paths ...[]string) bool {
for _, path := range paths {
value, ok := codexPathValue(obj, path)
if !ok {
continue
}
switch value := value.(type) {
case bool:
return value
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err == nil {
return parsed
}
}
}
return false
}
func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) {
value := firstCodexString(obj, path)
if value != "" {
@@ -20,6 +20,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
"aws_secret_access_key": "aws-secret",
"service_account_json": map[string]any{"private_key": "..."},
"private_key": "raw-key",
"agent_private_key": "agent-key-secret",
// 非敏感
"base_url": "https://api.example.com",
"model_mapping": map[string]any{"foo": "bar"},
@@ -35,6 +36,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
require.NotContains(t, out, "aws_secret_access_key")
require.NotContains(t, out, "service_account_json")
require.NotContains(t, out, "private_key")
require.NotContains(t, out, "agent_private_key")
require.Equal(t, "https://api.example.com", out["base_url"])
require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"])
@@ -47,6 +49,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
require.True(t, status["has_aws_secret_access_key"])
require.True(t, status["has_service_account_json"])
require.True(t, status["has_private_key"])
require.True(t, status["has_agent_private_key"])
// 状态 map 不应携带非敏感键的 has_*
require.NotContains(t, status, "has_base_url")
@@ -84,6 +87,7 @@ func TestRedactCredentials_AllKnownSensitiveKeys(t *testing.T) {
"api_key", "session_key", "cookie",
"aws_secret_access_key", "aws_session_token",
"service_account_json", "service_account", "private_key",
"agent_private_key",
}
in := make(map[string]any, len(keys))
for _, k := range keys {
@@ -4,7 +4,7 @@ package service
// dto 层做响应脱敏、service 层做更新合并都引用此清单——新增凭证类型时务必同步。
var SensitiveCredentialKeys = []string{
// OAuth
"access_token", "refresh_token", "id_token",
"access_token", "refresh_token", "id_token", "agent_private_key",
// API Key 类
"api_key", "session_key", "cookie",
// 云服务凭据
@@ -15,6 +15,7 @@ import (
"net/http/httptest"
"regexp"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -72,6 +73,7 @@ type AccountTestService struct {
httpUpstream HTTPUpstream
cfg *config.Config
tlsFPProfileService *TLSFingerprintProfileService
agentIdentityTaskMu sync.Mutex
}
// NewAccountTestService creates a new AccountTestService
@@ -544,9 +546,11 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
if credentialAccount.IsOAuth() {
isOAuth = true
// OAuth - use Bearer token with ChatGPT internal API
authToken = credentialAccount.GetOpenAIAccessToken()
if authToken == "" {
// Agent Identity signs each request and does not retain the OAuth token.
if !credentialAccount.IsOpenAIAgentIdentity() {
authToken = credentialAccount.GetOpenAIAccessToken()
}
if authToken == "" && !credentialAccount.IsOpenAIAgentIdentity() {
return s.sendErrorAndEnd(c, "No access token available")
}
@@ -597,7 +601,19 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
// Set common headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
if credentialAccount.IsOpenAIAgentIdentity() {
authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, nil, &s.agentIdentityTaskMu, credentialAccount)
if authErr != nil {
return s.sendErrorAndEnd(c, "Failed to build Agent Identity authentication")
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
} else {
req.Header.Set("Authorization", "Bearer "+authToken)
}
// Set OAuth-specific headers for ChatGPT internal API
if isOAuth {
@@ -804,16 +820,26 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection(
// resulting capability state on the account.
func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account *Account, testModelID string) error {
ctx := c.Request.Context()
credentialAccount := account
if account.IsShadow() {
resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return s.sendErrorAndEnd(c, "Failed to resolve account credentials")
}
credentialAccount = resolved
}
authToken := ""
apiURL := ""
isOAuth := false
switch {
case account.IsOAuth():
case credentialAccount.IsOAuth():
isOAuth = true
authToken = account.GetOpenAIAccessToken()
if authToken == "" {
if !credentialAccount.IsOpenAIAgentIdentity() {
authToken = credentialAccount.GetOpenAIAccessToken()
}
if authToken == "" && !credentialAccount.IsOpenAIAgentIdentity() {
return s.sendErrorAndEnd(c, "No access token available")
}
apiURL = chatgptCodexAPIURL + "/compact"
@@ -852,7 +878,19 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
if credentialAccount.IsOpenAIAgentIdentity() {
authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, nil, &s.agentIdentityTaskMu, credentialAccount)
if authErr != nil {
return s.sendErrorAndEnd(c, "Failed to build Agent Identity authentication")
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
} else {
req.Header.Set("Authorization", "Bearer "+authToken)
}
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("User-Agent", codexCLIUserAgent)
@@ -863,7 +901,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
if isOAuth {
req.Host = "chatgpt.com"
setOpenAIChatGPTAccountHeaders(req.Header, account)
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
}
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
@@ -1677,8 +1715,19 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via Codex /responses API.
func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
authToken := account.GetOpenAIAccessToken()
if authToken == "" {
credentialAccount := account
if account.IsShadow() {
resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return s.sendErrorAndEnd(c, "Failed to resolve account credentials")
}
credentialAccount = resolved
}
authToken := ""
if !credentialAccount.IsOpenAIAgentIdentity() {
authToken = credentialAccount.GetOpenAIAccessToken()
}
if authToken == "" && !credentialAccount.IsOpenAIAgentIdentity() {
return s.sendErrorAndEnd(c, "No access token available")
}
@@ -1710,17 +1759,29 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
req.Host = "chatgpt.com"
req.Header.Set("Authorization", "Bearer "+authToken)
if credentialAccount.IsOpenAIAgentIdentity() {
authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, nil, &s.agentIdentityTaskMu, credentialAccount)
if authErr != nil {
return s.sendErrorAndEnd(c, "Failed to build Agent Identity authentication")
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
} else {
req.Header.Set("Authorization", "Bearer "+authToken)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("originator", "codex_cli_rs")
if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
req.Header.Set("User-Agent", codexCLIUserAgent)
}
setOpenAIChatGPTAccountHeaders(req.Header, account)
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致:originator 与最终 User-Agent 首段配套(原 opencode 与 Codex UA 错配会 404issue #3901)。
enforceCodexIdentityHeaders(req.Header)
@@ -291,6 +291,7 @@ type AccountUsageService struct {
cache *UsageCache
identityCache IdentityCache
tlsFPProfileService *TLSFingerprintProfileService
agentIdentityTaskMu sync.Mutex
}
// NewAccountUsageService 创建AccountUsageService实例
@@ -682,8 +683,11 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
if account == nil || !account.IsOAuth() {
return nil, nil
}
accessToken := account.GetOpenAIAccessToken()
if accessToken == "" {
accessToken := ""
if !account.IsOpenAIAgentIdentity() {
accessToken = account.GetOpenAIAccessToken()
}
if accessToken == "" && !account.IsOpenAIAgentIdentity() {
return nil, fmt.Errorf("no access token available")
}
modelID := openaipkg.DefaultTestModel
@@ -701,7 +705,19 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
}
req.Host = "chatgpt.com"
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
if account.IsOpenAIAgentIdentity() {
authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, nil, &s.agentIdentityTaskMu, account)
if authErr != nil {
return nil, fmt.Errorf("build Agent Identity authentication: %w", authErr)
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
} else {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", "codex_cli_rs")
@@ -0,0 +1,431 @@
package service
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/nacl/box"
)
const (
OpenAIAuthModeAgentIdentity = "agentIdentity"
agentIdentityAuthAPIBaseURL = "https://auth.openai.com/api/accounts"
agentIdentityTaskRegistrationTimeout = 30 * time.Second
)
var openAIAgentIdentityAuthAPIBaseURL = agentIdentityAuthAPIBaseURL
type agentIdentityKey struct {
runtimeID string
privateKey ed25519.PrivateKey
taskID string
}
type agentIdentityTaskRegistrationResponse struct {
TaskID string `json:"task_id"`
TaskIDCamel string `json:"taskId"`
EncryptedTaskID string `json:"encrypted_task_id"`
EncryptedTaskIDCamel string `json:"encryptedTaskId"`
}
type agentIdentityTaskRecoveredError struct{}
func (e *agentIdentityTaskRecoveredError) Error() string {
return "agent identity task recovered"
}
func (a *Account) IsOpenAIAgentIdentity() bool {
if a == nil || !a.IsOpenAIOAuth() {
return false
}
return strings.EqualFold(strings.TrimSpace(a.GetCredential(openAIAuthModeCredentialKey)), OpenAIAuthModeAgentIdentity)
}
func agentIdentityPrivateKey(account *Account) (ed25519.PrivateKey, error) {
if account == nil {
return nil, errors.New("agent identity account is nil")
}
raw := strings.TrimSpace(account.GetCredential("agent_private_key"))
if raw == "" {
return nil, errors.New("agent identity private key is missing")
}
der, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, errors.New("agent identity private key is not valid base64")
}
key, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return nil, errors.New("agent identity private key is not valid PKCS#8")
}
privateKey, ok := key.(ed25519.PrivateKey)
if !ok || len(privateKey) != ed25519.PrivateKeySize {
return nil, errors.New("agent identity private key is not Ed25519")
}
return privateKey, nil
}
// ValidateOpenAIAgentIdentityPrivateKey validates the stored PKCS#8 Ed25519
// form without returning or logging the key material.
func ValidateOpenAIAgentIdentityPrivateKey(encoded string) error {
account := &Account{Credentials: map[string]any{"agent_private_key": encoded}}
_, err := agentIdentityPrivateKey(account)
return err
}
func agentIdentityKeyFromAccount(account *Account) (agentIdentityKey, error) {
privateKey, err := agentIdentityPrivateKey(account)
if err != nil {
return agentIdentityKey{}, err
}
runtimeID := strings.TrimSpace(account.GetCredential("agent_runtime_id"))
if runtimeID == "" {
return agentIdentityKey{}, errors.New("agent identity runtime id is missing")
}
return agentIdentityKey{
runtimeID: runtimeID,
privateKey: privateKey,
taskID: strings.TrimSpace(account.GetCredential("task_id")),
}, nil
}
func buildAgentAssertion(key agentIdentityKey, now time.Time) (string, error) {
if key.runtimeID == "" || key.taskID == "" {
return "", errors.New("agent identity runtime or task id is missing")
}
timestamp := now.UTC().Format(time.RFC3339)
payload := []byte(key.runtimeID + ":" + key.taskID + ":" + timestamp)
signature, err := key.privateKey.Sign(nil, payload, crypto.Hash(0))
if err != nil {
return "", errors.New("failed to sign agent assertion")
}
envelope := map[string]string{
"agent_runtime_id": key.runtimeID,
"task_id": key.taskID,
"timestamp": timestamp,
"signature": base64.StdEncoding.EncodeToString(signature),
}
encoded, err := json.Marshal(envelope)
if err != nil {
return "", errors.New("failed to serialize agent assertion")
}
return "AgentAssertion " + base64.RawURLEncoding.EncodeToString(encoded), nil
}
func signAgentTaskRegistration(key agentIdentityKey, timestamp time.Time) (string, string, error) {
if key.runtimeID == "" {
return "", "", errors.New("agent identity runtime id is missing")
}
formatted := timestamp.UTC().Format(time.RFC3339)
signature, err := key.privateKey.Sign(nil, []byte(key.runtimeID+":"+formatted), crypto.Hash(0))
if err != nil {
return "", "", errors.New("failed to sign agent task registration")
}
return formatted, base64.StdEncoding.EncodeToString(signature), nil
}
func decryptAgentTaskID(key agentIdentityKey, encoded string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return "", errors.New("encrypted agent task id is not valid base64")
}
seed := key.privateKey.Seed()
digest := sha512.Sum512(seed)
var curvePrivate [32]byte
copy(curvePrivate[:], digest[:32])
curvePrivate[0] &= 248
curvePrivate[31] &= 127
curvePrivate[31] |= 64
curvePublicBytes, err := curve25519.X25519(curvePrivate[:], curve25519.Basepoint)
if err != nil {
return "", errors.New("failed to derive agent identity decryption key")
}
var curvePublic [32]byte
copy(curvePublic[:], curvePublicBytes)
plaintext, ok := box.OpenAnonymous(nil, ciphertext, &curvePublic, &curvePrivate)
if !ok {
return "", errors.New("failed to decrypt encrypted agent task id")
}
taskID := strings.TrimSpace(string(plaintext))
if taskID == "" {
return "", errors.New("decrypted agent task id is empty")
}
return taskID, nil
}
func registerAgentIdentityTask(ctx context.Context, account *Account) (string, error) {
key, err := agentIdentityKeyFromAccount(account)
if err != nil {
return "", err
}
timestamp, signature, err := signAgentTaskRegistration(key, time.Now())
if err != nil {
return "", err
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
client, err := httpclient.GetClient(httpclient.Options{
ProxyURL: proxyURL,
Timeout: agentIdentityTaskRegistrationTimeout,
ResponseHeaderTimeout: 15 * time.Second,
})
if err != nil {
return "", errors.New("invalid proxy configuration for agent task registration")
}
body, err := json.Marshal(map[string]string{
"timestamp": timestamp,
"signature": signature,
})
if err != nil {
return "", errors.New("failed to serialize agent task registration")
}
url := strings.TrimRight(strings.TrimSpace(openAIAgentIdentityAuthAPIBaseURL), "/") + "/v1/agent/" + key.runtimeID + "/task/register"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
if err != nil {
return "", errors.New("failed to build agent task registration request")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", errors.New("agent task registration request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("agent task registration returned status %d", resp.StatusCode)
}
var result agentIdentityTaskRegistrationResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&result); err != nil {
return "", errors.New("agent task registration response is invalid")
}
if taskID := strings.TrimSpace(result.TaskID); taskID != "" {
return taskID, nil
}
if taskID := strings.TrimSpace(result.TaskIDCamel); taskID != "" {
return taskID, nil
}
encrypted := strings.TrimSpace(result.EncryptedTaskID)
if encrypted == "" {
encrypted = strings.TrimSpace(result.EncryptedTaskIDCamel)
}
if encrypted == "" {
return "", errors.New("agent task registration response omitted task id")
}
return decryptAgentTaskID(key, encrypted)
}
func ensureAgentIdentityTaskForAccount(ctx context.Context, repo AccountRepository, pool *openAIWSConnPool, taskMu *sync.Mutex, account *Account, expectedTaskID string) error {
if account == nil || !account.IsOpenAIAgentIdentity() {
return nil
}
credAccount := account
if account.IsShadow() {
resolved, err := resolveCredentialAccount(ctx, repo, account)
if err != nil {
return err
}
credAccount = resolved
}
if credAccount == nil || !credAccount.IsOpenAIAgentIdentity() {
return errors.New("agent identity credentials are unavailable")
}
currentTaskID := strings.TrimSpace(credAccount.GetCredential("task_id"))
if currentTaskID != "" && (expectedTaskID == "" || currentTaskID != expectedTaskID) {
return nil
}
if taskMu == nil {
return errors.New("agent identity task lock is unavailable")
}
taskMu.Lock()
defer taskMu.Unlock()
currentTaskID = strings.TrimSpace(credAccount.GetCredential("task_id"))
if currentTaskID != "" && (expectedTaskID == "" || currentTaskID != expectedTaskID) {
return nil
}
newTaskID, err := registerAgentIdentityTask(ctx, credAccount)
if err != nil {
return err
}
credentials := make(map[string]any, len(credAccount.Credentials)+1)
for key, value := range credAccount.Credentials {
credentials[key] = value
}
credentials["task_id"] = newTaskID
if err := persistAccountCredentials(ctx, repo, credAccount, credentials); err != nil {
return err
}
if pool != nil {
pool.ClearAccount(credAccount.ID)
}
return nil
}
func (s *OpenAIGatewayService) ensureAgentIdentityTask(ctx context.Context, account *Account, expectedTaskID string) error {
if s == nil {
return errors.New("openai gateway service is nil")
}
return ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s.openaiWSPool, &s.agentIdentityTaskMu, account, expectedTaskID)
}
func isAgentIdentityTaskInvalidHTTPResponse(statusCode int, body []byte) bool {
if statusCode != http.StatusUnauthorized {
return false
}
lower := strings.ToLower(string(body))
for _, marker := range []string{
"invalid task",
"task_id",
"task id",
"task_not_found",
"task_expired",
"unknown task",
} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func (s *OpenAIGatewayService) buildOpenAIAuthenticationHeaders(ctx context.Context, account *Account, token string) (http.Header, error) {
if account == nil {
return nil, errors.New("account is nil")
}
credAccount := account
if account.IsShadow() {
resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return nil, err
}
credAccount = resolved
}
headers := make(http.Header)
if credAccount != nil && credAccount.IsOpenAIAgentIdentity() {
agentHeaders, err := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.openaiWSPool, &s.agentIdentityTaskMu, credAccount)
if err != nil {
return nil, err
}
return agentHeaders, nil
}
headers.Set("Authorization", "Bearer "+token)
return headers, nil
}
func buildAgentIdentityAuthenticationHeaders(ctx context.Context, repo AccountRepository, pool *openAIWSConnPool, taskMu *sync.Mutex, account *Account) (http.Header, error) {
if account == nil || !account.IsOpenAIAgentIdentity() {
return nil, errors.New("agent identity account is required")
}
if err := ensureAgentIdentityTaskForAccount(ctx, repo, pool, taskMu, account, ""); err != nil {
return nil, err
}
key, err := agentIdentityKeyFromAccount(account)
if err != nil {
return nil, err
}
assertion, err := buildAgentAssertion(key, time.Now())
if err != nil {
return nil, err
}
headers := make(http.Header)
headers.Set("Authorization", assertion)
return headers, nil
}
func (s *OpenAIGatewayService) refreshOpenAIAgentIdentityHeaders(ctx context.Context, account *Account, headers http.Header) (http.Header, error) {
if account == nil {
return cloneHeader(headers), nil
}
credAccount := account
if account.IsShadow() {
resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return nil, err
}
credAccount = resolved
}
if !credAccount.IsOpenAIAgentIdentity() {
return cloneHeader(headers), nil
}
refreshed := cloneHeader(headers)
if refreshed == nil {
refreshed = make(http.Header)
}
authHeaders, err := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.openaiWSPool, &s.agentIdentityTaskMu, credAccount)
if err != nil {
return nil, err
}
refreshed.Set("Authorization", authHeaders.Get("Authorization"))
return refreshed, nil
}
func (s *OpenAIGatewayService) recoverAgentIdentityTask(ctx context.Context, account *Account, expectedTaskID string) error {
if account != nil && account.IsShadow() {
if resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account); err == nil && resolved != nil && strings.TrimSpace(expectedTaskID) == "" {
expectedTaskID = strings.TrimSpace(resolved.GetCredential("task_id"))
}
}
return s.ensureAgentIdentityTask(ctx, account, expectedTaskID)
}
func (s *OpenAIGatewayService) isAgentIdentityAccount(ctx context.Context, account *Account) bool {
if account == nil {
return false
}
credAccount := account
if account.IsShadow() {
resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return false
}
credAccount = resolved
}
return credAccount != nil && credAccount.IsOpenAIAgentIdentity()
}
// redactAgentIdentitySensitiveBody removes credential values before an
// upstream error can reach logs, ops events, or returned error text. Agent
// Identity responses should not echo these values, but keeping this boundary
// defensive prevents accidental disclosure if an upstream error does.
func (s *OpenAIGatewayService) redactAgentIdentitySensitiveBody(ctx context.Context, account *Account, body []byte) []byte {
if !s.isAgentIdentityAccount(ctx, account) || len(body) == 0 {
return body
}
credAccount := account
if account != nil && account.IsShadow() {
if resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account); err == nil && resolved != nil {
credAccount = resolved
}
}
redacted := string(body)
for _, key := range []string{
"agent_private_key",
"agent_runtime_id",
"task_id",
"access_token",
"refresh_token",
"id_token",
"api_key",
"session_key",
"cookie",
} {
if value := strings.TrimSpace(credAccount.GetCredential(key)); value != "" {
redacted = strings.ReplaceAll(redacted, value, "[redacted]")
}
}
return []byte(redacted)
}
@@ -0,0 +1,247 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestAccountTestServiceOpenAICompactAgentIdentityUsesFreshAssertion(t *testing.T) {
gin.SetMode(gin.TestMode)
key, privateKey := newTestAgentIdentityKey(t)
account := Account{
ID: 21,
Name: "agent-identity",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"auth_mode": OpenAIAuthModeAgentIdentity,
"agent_runtime_id": key.runtimeID,
"agent_private_key": privateKey,
"task_id": key.taskID,
"chatgpt_account_id": "account-agent-test",
"chatgpt_account_is_fedramp": true,
},
}
repo := &snapshotUpdateAccountRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"compact-agent","status":"completed"}`)),
}}
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/21/test", bytes.NewReader(nil))
require.NoError(t, svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact))
require.Equal(t, "AgentAssertion", strings.SplitN(upstream.lastReq.Header.Get("Authorization"), " ", 2)[0])
require.Equal(t, "account-agent-test", upstream.lastReq.Header.Get("chatgpt-account-id"))
require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp"))
require.NotContains(t, upstream.lastReq.Header.Get("Authorization"), privateKey)
}
func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
key, privateKey := newTestAgentIdentityKey(t)
account := &Account{
ID: 24,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"auth_mode": OpenAIAuthModeAgentIdentity,
"agent_runtime_id": key.runtimeID,
"agent_private_key": privateKey,
"task_id": key.taskID,
"chatgpt_account_id": "account-agent-passthrough",
},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":true,"prompt_cache_key":"cache-agent"}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
c.Request.Header.Set("session_id", "client-session")
c.Request.Header.Set("conversation_id", "client-conversation")
c.Request.Header.Set("Authorization", "Bearer inbound-must-not-forward")
svc := &OpenAIGatewayService{}
req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "")
require.NoError(t, err)
require.Equal(t, "AgentAssertion", strings.SplitN(req.Header.Get("Authorization"), " ", 2)[0])
require.Equal(t, "account-agent-passthrough", req.Header.Get("chatgpt-account-id"))
require.NotEqual(t, "client-session", req.Header.Get("session_id"))
require.NotEqual(t, "client-conversation", req.Header.Get("conversation_id"))
require.Equal(t, isolateOpenAISessionID(0, "cache-agent"), req.Header.Get("session_id"))
}
func TestOpenAIAgentIdentityErrorRedactionDoesNotLeakCredentialValues(t *testing.T) {
key, privateKey := newTestAgentIdentityKey(t)
account := &Account{
ID: 25,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"auth_mode": OpenAIAuthModeAgentIdentity,
"agent_runtime_id": key.runtimeID,
"agent_private_key": privateKey,
"task_id": key.taskID,
"access_token": key.runtimeID + "-oauth-value",
},
}
svc := &OpenAIGatewayService{}
oauthValue := account.GetCredential("access_token")
redacted := svc.redactAgentIdentitySensitiveBody(context.Background(), account, []byte(`{"message":"runtime-test task-test `+oauthValue+`"}`))
require.NotContains(t, string(redacted), key.runtimeID)
require.NotContains(t, string(redacted), key.taskID)
require.NotContains(t, string(redacted), oauthValue)
require.Contains(t, string(redacted), "[redacted]")
}
func TestOpenAIWSConnPoolHeadersFactoryRunsAtDialAndStalePrewarmIsDiscarded(t *testing.T) {
cfg := &config.Config{}
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
pool := newOpenAIWSConnPool(cfg)
defer pool.Close()
pool.setClientDialerForTest(&openAIWSFakeDialer{})
accountID := int64(22)
ap := pool.getOrCreateAccountPool(accountID)
factoryCalls := 0
latestHeader := ""
req := openAIWSAcquireRequest{
Account: &Account{ID: accountID, Platform: PlatformOpenAI, Type: AccountTypeOAuth},
WSURL: "wss://example.com/v1/responses",
HeadersFactory: func(_ context.Context, headers http.Header) (http.Header, error) {
factoryCalls++
latestHeader = "AgentAssertion dial-" + string(rune('0'+factoryCalls))
if headers == nil {
headers = make(http.Header)
}
headers.Set("Authorization", latestHeader)
return headers, nil
},
}
ap.mu.Lock()
ap.lastAcquire = &req
generation := ap.generation
ap.mu.Unlock()
pool.prewarmConns(accountID, req, 1, generation)
require.Equal(t, 1, factoryCalls, "prewarm must generate authorization inside the actual dial")
require.Equal(t, "AgentAssertion dial-1", latestHeader)
pool.ClearAccount(accountID)
ap.mu.Lock()
require.Empty(t, ap.conns, "credential recovery must remove pooled connections")
require.Nil(t, ap.lastAcquire, "credential recovery must discard delayed acquire state")
require.Equal(t, generation+1, ap.generation)
ap.mu.Unlock()
// A prewarm captured before ClearAccount must not be admitted after recovery.
pool.prewarmConns(accountID, req, 1, generation)
ap.mu.Lock()
require.Empty(t, ap.conns)
ap.mu.Unlock()
}
func TestOpenAIAgentIdentityTaskInvalidRetriesExactlyOnce(t *testing.T) {
gin.SetMode(gin.TestMode)
key, privateKey := newTestAgentIdentityKey(t)
account := &Account{
ID: 23,
Name: "agent-identity",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"auth_mode": OpenAIAuthModeAgentIdentity,
"agent_runtime_id": key.runtimeID,
"agent_private_key": privateKey,
"task_id": "task-old",
"chatgpt_account_id": "account-agent-retry",
},
}
repo := &agentIdentityForwardRepo{account: account}
registerCalls := 0
registerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
registerCalls++
_, _ = io.WriteString(w, `{"task_id":"task-new"}`)
}))
defer registerServer.Close()
oldBase := openAIAgentIdentityAuthAPIBaseURL
openAIAgentIdentityAuthAPIBaseURL = registerServer.URL
t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
successBody := `{"id":"resp-agent-retry","object":"response","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
upstream := &httpUpstreamRecorder{responses: []*http.Response{
{StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(successBody))},
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: repo, httpUpstream: upstream}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
_, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
require.NoError(t, err)
require.Equal(t, 1, registerCalls)
require.Len(t, upstream.requests, 2)
require.NotEqual(t, upstream.requests[0].Header.Get("Authorization"), upstream.requests[1].Header.Get("Authorization"))
require.Equal(t, "task-new", decodeAgentAssertionTask(t, upstream.requests[1].Header.Get("Authorization")))
// Two consecutive invalid responses still produce only one retry for this
// request; the recovery path must not loop indefinitely.
upstream.responses = []*http.Response{
{StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
{StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
}
rec2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(rec2)
c2.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
_, err = svc.Forward(context.Background(), c2, account, []byte(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
require.Error(t, err)
require.Equal(t, 2, registerCalls)
require.Len(t, upstream.requests, 4)
}
func decodeAgentAssertionTask(t *testing.T, header string) string {
t.Helper()
encoded := strings.TrimPrefix(header, "AgentAssertion ")
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
require.NoError(t, err)
var envelope struct {
TaskID string `json:"task_id"`
}
require.NoError(t, json.Unmarshal(decoded, &envelope))
return envelope.TaskID
}
type agentIdentityForwardRepo struct {
AccountRepository
account *Account
}
func (r *agentIdentityForwardRepo) GetByID(_ context.Context, _ int64) (*Account, error) {
return r.account, nil
}
func (r *agentIdentityForwardRepo) UpdateCredentials(_ context.Context, _ int64, credentials map[string]any) error {
r.account.Credentials = credentials
return nil
}
@@ -0,0 +1,177 @@
package service
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/nacl/box"
)
func newTestAgentIdentityKey(t *testing.T) (agentIdentityKey, string) {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
return agentIdentityKey{
runtimeID: "runtime-test",
privateKey: privateKey,
taskID: "task-test",
}, base64.StdEncoding.EncodeToString(der)
}
func TestBuildAgentAssertionMatchesCodexEnvelopeAndSignature(t *testing.T) {
key, _ := newTestAgentIdentityKey(t)
now := time.Date(2026, 7, 14, 8, 9, 10, 0, time.FixedZone("UTC+8", 8*60*60))
assertion, err := buildAgentAssertion(key, now)
require.NoError(t, err)
require.True(t, strings.HasPrefix(assertion, "AgentAssertion "))
encoded := strings.TrimPrefix(assertion, "AgentAssertion ")
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
require.NoError(t, err)
var envelope struct {
AgentRuntimeID string `json:"agent_runtime_id"`
TaskID string `json:"task_id"`
Timestamp string `json:"timestamp"`
Signature string `json:"signature"`
}
require.NoError(t, json.Unmarshal(decoded, &envelope))
require.Equal(t, "runtime-test", envelope.AgentRuntimeID)
require.Equal(t, "task-test", envelope.TaskID)
require.Equal(t, "2026-07-14T00:09:10Z", envelope.Timestamp)
signature, err := base64.StdEncoding.DecodeString(envelope.Signature)
require.NoError(t, err)
require.True(t, ed25519.Verify(key.privateKey.Public().(ed25519.PublicKey), []byte("runtime-test:task-test:2026-07-14T00:09:10Z"), signature))
}
func TestDecryptAgentTaskIDSupportsCodexSealedBoxResponse(t *testing.T) {
key, _ := newTestAgentIdentityKey(t)
digest := sha512.Sum512(key.privateKey.Seed())
var curvePrivate [32]byte
copy(curvePrivate[:], digest[:32])
curvePrivate[0] &= 248
curvePrivate[31] &= 127
curvePrivate[31] |= 64
curvePublicBytes, err := curve25519.X25519(curvePrivate[:], curve25519.Basepoint)
require.NoError(t, err)
var curvePublic [32]byte
copy(curvePublic[:], curvePublicBytes)
ciphertext, err := box.SealAnonymous(nil, []byte("task-sealed"), &curvePublic, rand.Reader)
require.NoError(t, err)
got, err := decryptAgentTaskID(key, base64.StdEncoding.EncodeToString(ciphertext))
require.NoError(t, err)
require.Equal(t, "task-sealed", got)
}
func TestRegisterAgentIdentityTaskAcceptsPlaintextAndEncryptedResponses(t *testing.T) {
key, privateKey := newTestAgentIdentityKey(t)
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/v1/agent/runtime-test/task/register", r.URL.Path)
var request map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.NotEmpty(t, request["timestamp"])
require.NotEmpty(t, request["signature"])
requestCount++
if requestCount == 2 {
digest := sha512.Sum512(key.privateKey.Seed())
var curvePrivate [32]byte
copy(curvePrivate[:], digest[:32])
curvePrivate[0] &= 248
curvePrivate[31] &= 127
curvePrivate[31] |= 64
curvePublicBytes, curveErr := curve25519.X25519(curvePrivate[:], curve25519.Basepoint)
require.NoError(t, curveErr)
var curvePublic [32]byte
copy(curvePublic[:], curvePublicBytes)
ciphertext, sealErr := box.SealAnonymous(nil, []byte("task-encrypted"), &curvePublic, rand.Reader)
require.NoError(t, sealErr)
_, _ = fmt.Fprintf(w, `{"encrypted_task_id":%q}`, base64.StdEncoding.EncodeToString(ciphertext))
return
}
_, _ = w.Write([]byte(`{"task_id":"task-plain"}`))
}))
defer server.Close()
oldBase := openAIAgentIdentityAuthAPIBaseURL
openAIAgentIdentityAuthAPIBaseURL = server.URL
t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
account := &Account{ID: 1, Type: AccountTypeOAuth, Platform: PlatformOpenAI, Credentials: map[string]any{
"auth_mode": OpenAIAuthModeAgentIdentity,
"agent_runtime_id": key.runtimeID,
"agent_private_key": privateKey,
}}
taskID, err := registerAgentIdentityTask(context.Background(), account)
require.NoError(t, err)
require.Equal(t, "task-plain", taskID)
taskID, err = registerAgentIdentityTask(context.Background(), account)
require.NoError(t, err)
require.Equal(t, "task-encrypted", taskID)
}
func TestEnsureAgentIdentityTaskPersistsAndRedactsCredentials(t *testing.T) {
key, privateKey := newTestAgentIdentityKey(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"task_id":"task-persisted"}`))
}))
defer server.Close()
oldBase := openAIAgentIdentityAuthAPIBaseURL
openAIAgentIdentityAuthAPIBaseURL = server.URL
t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
repo := &agentIdentityCredentialsRepo{}
account := &Account{ID: 7, Type: AccountTypeOAuth, Platform: PlatformOpenAI, Credentials: map[string]any{
"auth_mode": OpenAIAuthModeAgentIdentity,
"agent_runtime_id": key.runtimeID,
"agent_private_key": privateKey,
"chatgpt_account_id": "account-test",
}}
service := &OpenAIGatewayService{accountRepo: repo}
require.NoError(t, service.ensureAgentIdentityTask(context.Background(), account, ""))
require.Equal(t, "task-persisted", account.GetCredential("task_id"))
require.Equal(t, "task-persisted", repo.credentials["task_id"])
require.True(t, IsSensitiveCredentialKey("agent_private_key"))
redacted := make(map[string]any)
for key, value := range account.Credentials {
if !IsSensitiveCredentialKey(key) {
redacted[key] = value
}
}
require.NotContains(t, string(mustJSON(t, redacted)), privateKey)
}
type agentIdentityCredentialsRepo struct {
AccountRepository
credentials map[string]any
mu sync.Mutex
}
func (r *agentIdentityCredentialsRepo) UpdateCredentials(_ context.Context, _ int64, credentials map[string]any) error {
r.mu.Lock()
defer r.mu.Unlock()
r.credentials = credentials
return nil
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
encoded, err := json.Marshal(value)
require.NoError(t, err)
return encoded
}
@@ -42,7 +42,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_CREDENTIALS_FAILED", "resolve credential account: %v", err)
}
accessToken := credAccount.GetOpenAIAccessToken()
if accessToken == "" {
if accessToken == "" && !credAccount.IsOpenAIAgentIdentity() {
return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token")
}
@@ -58,7 +58,15 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc
if err != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "create codex models request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, credAccount, accessToken)
if err != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication: %v", err)
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Version", clientVersion)
@@ -215,7 +215,15 @@ func (s *OpenAIGatewayService) buildInputTokensUpstreamRequest(
return nil, err
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
req.Header.Set("authorization", "Bearer "+token)
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
if err != nil {
return nil, err
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
req.Header.Set("content-type", "application/json")
req.Header.Set("accept", "application/json")
@@ -487,6 +487,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
var wsResult *OpenAIForwardResult
var wsErr error
wsLastFailureReason := ""
agentTaskRecoveryTried := false
wsPrevResponseRecoveryTried := false
wsInvalidEncryptedContentRecoveryTried := false
recoverPrevResponseNotFound := func(attempt int) bool {
@@ -571,6 +572,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
startTime,
attempt,
wsLastFailureReason,
&agentTaskRecoveryTried,
)
if wsErr == nil {
break
@@ -578,6 +580,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if c != nil && c.Writer != nil && c.Writer.Written() {
break
}
var taskRecoveredErr *agentIdentityTaskRecoveredError
if errors.As(wsErr, &taskRecoveredErr) {
continue
}
reason, retryable := classifyOpenAIWSReconnectReason(wsErr)
if reason != "" {
@@ -684,6 +690,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
httpInvalidEncryptedContentRetryTried := false
agentTaskRecoveryTried := false
for {
// Build upstream request
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
@@ -719,6 +726,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
upstreamCode := extractUpstreamErrorCode(respBody)
if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) {
agentTaskRecoveryTried = true
expectedTaskID := account.GetCredential("task_id")
if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil {
return nil, fmt.Errorf("agent identity task recovery failed: %w", err)
}
continue
}
respBody = s.redactAgentIdentitySensitiveBody(ctx, account, respBody)
resp.Body = io.NopCloser(bytes.NewReader(respBody))
if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
@@ -869,8 +886,17 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
// Set authentication header
req.Header.Set("authorization", "Bearer "+token)
// Build authentication for this request. Agent Identity signs a fresh
// assertion here; OAuth/PAT/API-key keep their existing Bearer behavior.
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
if err != nil {
return nil, fmt.Errorf("build openai authentication headers: %w", err)
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
// Set headers specific to OAuth accounts (ChatGPT internal API)
if account.Type == AccountTypeOAuth {
@@ -338,7 +338,15 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
req.Header.Del("authorization")
req.Header.Del("x-api-key")
req.Header.Del("x-goog-api-key")
req.Header.Set("authorization", "Bearer "+token)
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
if err != nil {
return nil, fmt.Errorf("build openai authentication headers: %w", err)
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
// OAuth 透传到 ChatGPT internal API 时补齐必要头。
if account.Type == AccountTypeOAuth {
@@ -434,6 +442,7 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
requestBody []byte,
) error {
body := s.readUpstreamErrorBody(resp)
body = s.redactAgentIdentitySensitiveBody(ctx, account, body)
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
@@ -477,6 +486,7 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
) error {
MarkResponseCommitted(c)
body := s.readUpstreamErrorBody(resp)
body = s.redactAgentIdentitySensitiveBody(ctx, account, body)
// cyber_policy:透传账号本就把原始 body 回给客户端(下方 c.Data),此处仅打标记,
// 供 handler 事后写风控/邮件。cyber 是上游网络安全策略拦截,不冷却账号,
@@ -356,6 +356,7 @@ type OpenAIGatewayService struct {
openaiWSStateStoreOnce sync.Once
openaiSchedulerOnce sync.Once
openaiWSPassthroughDialerOnce sync.Once
agentIdentityTaskMu sync.Mutex
openaiWSPool *openAIWSConnPool
openaiWSStateStore OpenAIWSStateStore
openaiScheduler OpenAIAccountScheduler
@@ -1052,6 +1053,9 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco
}
switch account.Type {
case AccountTypeOAuth:
if account.IsOpenAIAgentIdentity() {
return "", OpenAIAuthModeAgentIdentity, nil
}
if account.Platform == PlatformGrok {
if s.grokTokenProvider != nil {
accessToken, err := s.grokTokenProvider.GetAccessToken(ctx, account)
@@ -279,6 +279,7 @@ func (s *OpenAIGatewayService) handleErrorResponse(
requestedModel ...string,
) (*OpenAIForwardResult, error) {
body := s.readUpstreamErrorBody(resp)
body = s.redactAgentIdentitySensitiveBody(ctx, account, body)
// cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。
// 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover
+9 -1
View File
@@ -752,7 +752,15 @@ func (s *OpenAIGatewayService) buildOpenAIImagesRequest(
return nil, err
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
req.Header.Set("Authorization", "Bearer "+token)
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
if err != nil {
return nil, fmt.Errorf("build openai authentication headers: %w", err)
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
for key, values := range c.Request.Header {
if !openaiPassthroughAllowedHeaders[strings.ToLower(key)] {
continue
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -118,6 +119,7 @@ type OpenAIQuotaService struct {
proxyRepo ProxyRepository
tokenProvider *OpenAITokenProvider
privacyClientFactory PrivacyClientFactory
agentIdentityTaskMu sync.Mutex
}
// NewOpenAIQuotaService constructs a quota service. token provider is required —
@@ -154,10 +156,14 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*
callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout)
defer cancel()
quotaHeaders, headerErr := s.buildCodexQuotaHeaders(callCtx, accountID, accessToken, chatGPTAccountID, fedRAMP)
if headerErr != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr)
}
var payload OpenAIQuotaUsage
resp, err := client.R().
SetContext(callCtx).
SetHeaders(buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)).
SetHeaders(quotaHeaders).
SetSuccessResult(&payload).
Get(chatGPTUsageURL)
if err != nil {
@@ -178,9 +184,14 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*
}
func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) []OpenAIRateLimitResetCreditDetail {
quotaHeaders, headerErr := s.buildCodexQuotaHeaders(ctx, accountID, accessToken, chatGPTAccountID, fedRAMP)
if headerErr != nil {
slog.Warn("openai_quota_reset_credit_details_auth_failed", "account_id", accountID, "error", headerErr)
return nil
}
resp, err := client.R().
SetContext(ctx).
SetHeaders(buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)).
SetHeaders(quotaHeaders).
Get(chatGPTRateLimitCreditsURL)
if err != nil {
slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "error", err)
@@ -239,7 +250,10 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (
callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout)
defer cancel()
headers := buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)
headers, headerErr := s.buildCodexQuotaHeaders(callCtx, accountID, accessToken, chatGPTAccountID, fedRAMP)
if headerErr != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr)
}
headers["content-type"] = "application/json"
var payload OpenAIQuotaResetResult
@@ -271,7 +285,7 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (
// token via the shared TokenProvider, and resolves the chatgpt-account-id and
// proxy URL. Centralized so QueryUsage / ResetCredit share validation.
func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID int64) (accessToken, chatGPTAccountID, proxyURL string, fedRAMP bool, err error) {
if s == nil || s.accountRepo == nil || s.tokenProvider == nil || s.privacyClientFactory == nil {
if s == nil || s.accountRepo == nil || s.privacyClientFactory == nil {
return "", "", "", false, infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota service is not configured")
}
@@ -309,12 +323,17 @@ func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID
return "", "", "", false, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_MISSING_ACCOUNT_ID", "chatgpt_account_id is missing; please re-authorize this account")
}
accessToken, err = s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err)
}
if strings.TrimSpace(accessToken) == "" {
return "", "", "", false, infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "access token is empty")
if !account.IsOpenAIAgentIdentity() {
if s.tokenProvider == nil {
return "", "", "", false, infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota token provider is not configured")
}
accessToken, err = s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return "", "", "", false, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err)
}
if strings.TrimSpace(accessToken) == "" {
return "", "", "", false, infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_TOKEN_UNAVAILABLE", "access token is empty")
}
}
fedRAMP = account.IsChatGPTAccountFedRAMP()
@@ -337,6 +356,43 @@ func (s *OpenAIQuotaService) prepareUpstreamCall(ctx context.Context, accountID
return accessToken, chatGPTAccountID, proxyURL, fedRAMP, nil
}
func (s *OpenAIQuotaService) buildCodexQuotaHeaders(ctx context.Context, accountID int64, accessToken, chatGPTAccountID string, fedRAMP bool) (map[string]string, error) {
headers := buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)
if s == nil || s.accountRepo == nil {
return headers, nil
}
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil || account == nil {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("agent identity account credentials are unavailable")
}
return headers, nil
}
if account.IsShadow() {
if resolved, resolveErr := resolveCredentialAccount(ctx, s.accountRepo, account); resolveErr == nil && resolved != nil {
account = resolved
} else if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("agent identity shadow credentials are unavailable")
}
}
if !account.IsOpenAIAgentIdentity() {
return headers, nil
}
if err := ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, nil, &s.agentIdentityTaskMu, account, ""); err != nil {
return nil, err
}
key, err := agentIdentityKeyFromAccount(account)
if err != nil {
return nil, err
}
assertion, err := buildAgentAssertion(key, time.Now())
if err != nil {
return nil, err
}
headers["authorization"] = assertion
return headers, nil
}
// buildCodexCommonHeaders sets the request headers expected by the chatgpt.com
// backend so calls succeed past Cloudflare/WASM checks.
func buildCodexCommonHeaders(accessToken, chatGPTAccountID string, fedRAMP bool) map[string]string {
@@ -2,6 +2,10 @@ package service
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -214,6 +218,45 @@ func TestPrepareUpstreamCallShadowResolve(t *testing.T) {
"prepareUpstreamCall should use parent's chatgpt_account_id after shadow resolve")
}
func TestQueryUsageAgentIdentityUsesAssertionWithoutOAuthToken(t *testing.T) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
account := &Account{
ID: 300,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"auth_mode": OpenAIAuthModeAgentIdentity,
"agent_runtime_id": "runtime-quota",
"agent_private_key": base64.StdEncoding.EncodeToString(der),
"task_id": "task-quota",
"chatgpt_account_id": "account-quota",
"chatgpt_account_is_fedramp": true,
},
}
repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{account.ID: account}}
var authorization string
var accountHeader string
var fedrampHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authorization = r.Header.Get("authorization")
accountHeader = r.Header.Get("chatgpt-account-id")
fedrampHeader = r.Header.Get("x-openai-fedramp")
w.Header().Set("content-type", "application/json")
_, _ = w.Write([]byte(`{"plan_type":"pro","rate_limit":{"allowed":true}}`))
}))
defer srv.Close()
svc := NewOpenAIQuotaService(repo, nil, nil, newQuotaRedirectingFactory(srv))
usage, err := svc.QueryUsage(context.Background(), account.ID)
require.NoError(t, err)
require.NotNil(t, usage)
require.True(t, strings.HasPrefix(authorization, "AgentAssertion "))
require.Equal(t, "account-quota", accountHeader)
require.Equal(t, "true", fedrampHeader)
}
func TestParseOpenAIRateLimitResetCreditDetails_CompatibleContainers(t *testing.T) {
tests := []struct {
name string
@@ -539,6 +539,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
Account: account,
WSURL: wsURL,
Headers: wsHeaders,
HeadersFactory: func(factoryCtx context.Context, headers http.Header) (http.Header, error) {
return s.refreshOpenAIAgentIdentityHeaders(factoryCtx, account, headers)
},
ProxyURL: func() string {
if account.ProxyID != nil && account.Proxy != nil {
return account.Proxy.URL()
@@ -602,7 +605,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
acquireTimeout = 30 * time.Second
}
acquireTurnLease := func(turn int, preferred string, forcePreferredConn bool) (*openAIWSConnLease, error) {
agentTaskRecoveryTried := false
var acquireTurnLease func(int, string, bool) (*openAIWSConnLease, error)
acquireTurnLease = func(turn int, preferred string, forcePreferredConn bool) (*openAIWSConnLease, error) {
req := cloneOpenAIWSAcquireRequest(baseAcquireReq)
req.PreferredConnID = strings.TrimSpace(preferred)
req.ForcePreferredConn = forcePreferredConn
@@ -611,6 +616,14 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
acquireCtx, acquireCancel := context.WithTimeout(ctx, acquireTimeout)
lease, acquireErr := pool.Acquire(acquireCtx, req)
acquireCancel()
var dialErr *openAIWSDialError
if acquireErr != nil && s.isAgentIdentityAccount(ctx, account) && errors.As(acquireErr, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusUnauthorized && !agentTaskRecoveryTried {
agentTaskRecoveryTried = true
if recoveryErr := s.recoverAgentIdentityTask(ctx, account, account.GetCredential("task_id")); recoveryErr != nil {
return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr)
}
return acquireTurnLease(turn, preferred, forcePreferredConn)
}
if acquireErr != nil {
dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr)
logOpenAIWSModeInfo(
@@ -67,7 +67,9 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
promptCacheKey string,
) (http.Header, openAIWSSessionHeaderResolution, error) {
headers := make(http.Header)
headers.Set("authorization", "Bearer "+token)
if account == nil || !account.IsOpenAIAgentIdentity() {
headers.Set("authorization", "Bearer "+token)
}
sessionResolution := resolveOpenAIWSSessionHeaders(c, promptCacheKey)
if c != nil && c.Request != nil {
@@ -30,6 +30,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
startTime time.Time,
attempt int,
lastFailureReason string,
agentTaskRecoveryTried *bool,
) (*OpenAIForwardResult, error) {
if s == nil || account == nil {
return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil"))
@@ -174,9 +175,12 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
defer acquireCancel()
lease, err := s.getOpenAIWSConnPool().Acquire(acquireCtx, openAIWSAcquireRequest{
Account: account,
WSURL: wsURL,
Headers: wsHeaders,
Account: account,
WSURL: wsURL,
Headers: wsHeaders,
HeadersFactory: func(factoryCtx context.Context, headers http.Header) (http.Header, error) {
return s.refreshOpenAIAgentIdentityHeaders(factoryCtx, account, headers)
},
PreferredConnID: preferredConnID,
ForceNewConn: forceNewConn,
ProxyURL: func() string {
@@ -187,6 +191,14 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}(),
})
if err != nil {
var agentDialErr *openAIWSDialError
if s.isAgentIdentityAccount(ctx, account) && errors.As(err, &agentDialErr) && agentDialErr != nil && agentDialErr.StatusCode == http.StatusUnauthorized && agentTaskRecoveryTried != nil && !*agentTaskRecoveryTried {
*agentTaskRecoveryTried = true
if recoveryErr := s.recoverAgentIdentityTask(ctx, account, account.GetCredential("task_id")); recoveryErr != nil {
return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr)
}
return nil, &agentIdentityTaskRecoveredError{}
}
dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(err)
logOpenAIWSModeInfo(
"acquire_fail account_id=%d account_type=%s transport=%s reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_new_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v",
+59 -6
View File
@@ -60,9 +60,13 @@ func (e *openAIWSDialError) Unwrap() error {
}
type openAIWSAcquireRequest struct {
Account *Account
WSURL string
Headers http.Header
Account *Account
WSURL string
Headers http.Header
// HeadersFactory is evaluated inside dialConn. It exists so credentials
// whose authorization is per-dial (Agent Identity) are never cached in
// lastAcquire or delayed prewarm state.
HeadersFactory func(context.Context, http.Header) (http.Header, error)
ProxyURL string
PreferredConnID string
// ForceNewConn: 强制本次获取新连接(避免复用导致连接内续链状态互相污染)。
@@ -517,6 +521,7 @@ type openAIWSAccountPool struct {
conns map[string]*openAIWSConn
pinnedConns map[string]int
creating int
generation uint64
lastCleanupAt time.Time
lastAcquire *openAIWSAcquireRequest
prewarmActive bool
@@ -1283,6 +1288,7 @@ func (p *openAIWSConnPool) ensureTargetIdleAsync(accountID int64) {
}
var req openAIWSAcquireRequest
generation := uint64(0)
need := 0
ap, ok := p.getAccountPool(accountID)
if !ok || ap == nil {
@@ -1317,6 +1323,7 @@ func (p *openAIWSConnPool) ensureTargetIdleAsync(accountID int64) {
return
}
req = cloneOpenAIWSAcquireRequest(*ap.lastAcquire)
generation = ap.generation
ap.prewarmActive = true
if cooldown := p.prewarmCooldown(); cooldown > 0 {
ap.prewarmUntil = now.Add(cooldown)
@@ -1324,7 +1331,7 @@ func (p *openAIWSConnPool) ensureTargetIdleAsync(accountID int64) {
ap.creating += need
p.metrics.scaleUpTotal.Add(int64(need))
go p.prewarmConns(accountID, req, need)
go p.prewarmConns(accountID, req, need, generation)
}
func (p *openAIWSConnPool) targetConnCountLocked(ap *openAIWSAccountPool, maxConns int) int {
@@ -1367,7 +1374,11 @@ func (p *openAIWSConnPool) targetConnCountLocked(ap *openAIWSAccountPool, maxCon
return target
}
func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequest, total int) {
func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequest, total int, generations ...uint64) {
generation := uint64(0)
if len(generations) > 0 {
generation = generations[0]
}
defer func() {
if ap, ok := p.getAccountPool(accountID); ok && ap != nil {
ap.mu.Lock()
@@ -1398,6 +1409,11 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ
ap.mu.Unlock()
continue
}
if ap.generation != generation || ap.lastAcquire == nil {
ap.mu.Unlock()
conn.close()
continue
}
if len(ap.conns) >= p.effectiveMaxConnsByAccount(req.Account) {
ap.mu.Unlock()
conn.close()
@@ -1410,6 +1426,35 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ
}
}
// ClearAccount closes all pooled connections and discards delayed prewarm
// state for one account. The generation guard prevents an in-flight prewarm
// started before credential recovery from re-entering the pool afterwards.
func (p *openAIWSConnPool) ClearAccount(accountID int64) {
if p == nil || accountID <= 0 {
return
}
ap, ok := p.getAccountPool(accountID)
if !ok || ap == nil {
return
}
ap.mu.Lock()
ap.generation++
conns := make([]*openAIWSConn, 0, len(ap.conns))
for id, conn := range ap.conns {
delete(ap.conns, id)
delete(ap.pinnedConns, id)
if conn != nil {
conns = append(conns, conn)
}
}
ap.lastAcquire = nil
ap.prewarmUntil = time.Time{}
ap.prewarmFails = 0
ap.prewarmFailAt = time.Time{}
ap.mu.Unlock()
closeOpenAIWSConns(conns)
}
func (p *openAIWSConnPool) evictConn(accountID int64, connID string) {
if p == nil || accountID <= 0 || stringsTrim(connID) == "" {
return
@@ -1485,7 +1530,15 @@ func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequ
if p == nil || p.clientDialer == nil {
return nil, errors.New("openai ws client dialer is nil")
}
conn, status, handshakeHeaders, err := p.clientDialer.Dial(ctx, req.WSURL, req.Headers, req.ProxyURL)
headers := cloneHeader(req.Headers)
var err error
if req.HeadersFactory != nil {
headers, err = req.HeadersFactory(ctx, headers)
if err != nil {
return nil, err
}
}
conn, status, handshakeHeaders, err := p.clientDialer.Dial(ctx, req.WSURL, headers, req.ProxyURL)
if err != nil {
return nil, &openAIWSDialError{
StatusCode: status,
@@ -359,10 +359,28 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
return errors.New("openai ws passthrough dialer is nil")
}
dialCtx, cancelDial := context.WithTimeout(ctx, s.openAIWSDialTimeout())
defer cancelDial()
upstreamConn, statusCode, handshakeHeaders, err := dialer.Dial(dialCtx, wsURL, headers, proxyURL)
if err != nil {
agentTaskRecoveryTried := false
var upstreamConn openAIWSClientConn
statusCode := 0
var handshakeHeaders http.Header
for {
headers, err = s.refreshOpenAIAgentIdentityHeaders(ctx, account, headers)
if err != nil {
return fmt.Errorf("refresh ws authentication headers: %w", err)
}
dialCtx, cancelDial := context.WithTimeout(ctx, s.openAIWSDialTimeout())
upstreamConn, statusCode, handshakeHeaders, err = dialer.Dial(dialCtx, wsURL, headers, proxyURL)
cancelDial()
if err == nil {
break
}
if s.isAgentIdentityAccount(ctx, account) && statusCode == http.StatusUnauthorized && !agentTaskRecoveryTried {
agentTaskRecoveryTried = true
if recoveryErr := s.recoverAgentIdentityTask(ctx, account, account.GetCredential("task_id")); recoveryErr != nil {
return fmt.Errorf("agent identity task recovery failed: %w", recoveryErr)
}
continue
}
logOpenAIWSV2Passthrough(
"relay_dial_failed account_id=%d status_code=%d err=%s",
account.ID,