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
32107b4f
Unverified
Commit
32107b4f
authored
Apr 22, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 22, 2026
Browse files
Merge pull request #1795 from 0x90000/feat/openai-image-api-sync
feat(openai): 同步生图 API 支持并接入图片计费调度
parents
8eb3f9e7
6ad333d6
Changes
17
Expand all
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/endpoint.go
View file @
32107b4f
...
...
@@ -15,10 +15,12 @@ import (
// ──────────────────────────────────────────────────────────
const
(
EndpointMessages
=
"/v1/messages"
EndpointChatCompletions
=
"/v1/chat/completions"
EndpointResponses
=
"/v1/responses"
EndpointGeminiModels
=
"/v1beta/models"
EndpointMessages
=
"/v1/messages"
EndpointChatCompletions
=
"/v1/chat/completions"
EndpointResponses
=
"/v1/responses"
EndpointImagesGenerations
=
"/v1/images/generations"
EndpointImagesEdits
=
"/v1/images/edits"
EndpointGeminiModels
=
"/v1beta/models"
)
// gin.Context keys used by the middleware and helpers below.
...
...
@@ -44,6 +46,10 @@ func NormalizeInboundEndpoint(path string) string {
return
EndpointChatCompletions
case
strings
.
Contains
(
path
,
EndpointMessages
)
:
return
EndpointMessages
case
strings
.
Contains
(
path
,
EndpointImagesGenerations
)
||
strings
.
Contains
(
path
,
"/images/generations"
)
:
return
EndpointImagesGenerations
case
strings
.
Contains
(
path
,
EndpointImagesEdits
)
||
strings
.
Contains
(
path
,
"/images/edits"
)
:
return
EndpointImagesEdits
case
strings
.
Contains
(
path
,
EndpointResponses
)
:
return
EndpointResponses
case
strings
.
Contains
(
path
,
EndpointGeminiModels
)
:
...
...
@@ -69,6 +75,9 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
switch
platform
{
case
service
.
PlatformOpenAI
:
if
inbound
==
EndpointImagesGenerations
||
inbound
==
EndpointImagesEdits
{
return
inbound
}
// OpenAI forwards everything to the Responses API.
// Preserve subresource suffix (e.g. /v1/responses/compact).
if
suffix
:=
responsesSubpathSuffix
(
rawRequestPath
);
suffix
!=
""
{
...
...
backend/internal/handler/endpoint_test.go
View file @
32107b4f
...
...
@@ -25,12 +25,16 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
{
"/v1/messages"
,
EndpointMessages
},
{
"/v1/chat/completions"
,
EndpointChatCompletions
},
{
"/v1/responses"
,
EndpointResponses
},
{
"/v1/images/generations"
,
EndpointImagesGenerations
},
{
"/v1/images/edits"
,
EndpointImagesEdits
},
{
"/v1beta/models"
,
EndpointGeminiModels
},
// Prefixed paths (antigravity, openai).
{
"/antigravity/v1/messages"
,
EndpointMessages
},
{
"/openai/v1/responses"
,
EndpointResponses
},
{
"/openai/v1/responses/compact"
,
EndpointResponses
},
{
"/openai/v1/images/generations"
,
EndpointImagesGenerations
},
{
"/openai/v1/images/edits"
,
EndpointImagesEdits
},
{
"/antigravity/v1beta/models/gemini:generateContent"
,
EndpointGeminiModels
},
// Gin route patterns with wildcards.
...
...
@@ -73,6 +77,8 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
{
"openai responses nested"
,
EndpointResponses
,
"/openai/v1/responses/compact/detail"
,
service
.
PlatformOpenAI
,
"/v1/responses/compact/detail"
},
{
"openai from messages"
,
EndpointMessages
,
"/v1/messages"
,
service
.
PlatformOpenAI
,
EndpointResponses
},
{
"openai from completions"
,
EndpointChatCompletions
,
"/v1/chat/completions"
,
service
.
PlatformOpenAI
,
EndpointResponses
},
{
"openai image generations"
,
EndpointImagesGenerations
,
"/v1/images/generations"
,
service
.
PlatformOpenAI
,
EndpointImagesGenerations
},
{
"openai image edits"
,
EndpointImagesEdits
,
"/openai/v1/images/edits"
,
service
.
PlatformOpenAI
,
EndpointImagesEdits
},
// Antigravity — uses inbound to pick Claude vs Gemini upstream.
{
"antigravity claude"
,
EndpointMessages
,
"/antigravity/v1/messages"
,
service
.
PlatformAntigravity
,
EndpointMessages
},
...
...
backend/internal/handler/openai_images.go
0 → 100644
View file @
32107b4f
package
handler
import
(
"context"
"errors"
"net/http"
"strings"
"time"
pkghttputil
"github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Images handles OpenAI Images API requests.
// POST /v1/images/generations
// POST /v1/images/edits
func
(
h
*
OpenAIGatewayHandler
)
Images
(
c
*
gin
.
Context
)
{
streamStarted
:=
false
defer
h
.
recoverResponsesPanic
(
c
,
&
streamStarted
)
requestStart
:=
time
.
Now
()
apiKey
,
ok
:=
middleware2
.
GetAPIKeyFromContext
(
c
)
if
!
ok
{
h
.
errorResponse
(
c
,
http
.
StatusUnauthorized
,
"authentication_error"
,
"Invalid API key"
)
return
}
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
h
.
errorResponse
(
c
,
http
.
StatusInternalServerError
,
"api_error"
,
"User context not found"
)
return
}
reqLog
:=
requestLogger
(
c
,
"handler.openai_gateway.images"
,
zap
.
Int64
(
"user_id"
,
subject
.
UserID
),
zap
.
Int64
(
"api_key_id"
,
apiKey
.
ID
),
zap
.
Any
(
"group_id"
,
apiKey
.
GroupID
),
)
if
!
h
.
ensureResponsesDependencies
(
c
,
reqLog
)
{
return
}
body
,
err
:=
pkghttputil
.
ReadRequestBodyWithPrealloc
(
c
.
Request
)
if
err
!=
nil
{
if
maxErr
,
ok
:=
extractMaxBytesError
(
err
);
ok
{
h
.
errorResponse
(
c
,
http
.
StatusRequestEntityTooLarge
,
"invalid_request_error"
,
buildBodyTooLargeMessage
(
maxErr
.
Limit
))
return
}
h
.
errorResponse
(
c
,
http
.
StatusBadRequest
,
"invalid_request_error"
,
"Failed to read request body"
)
return
}
if
len
(
body
)
==
0
{
h
.
errorResponse
(
c
,
http
.
StatusBadRequest
,
"invalid_request_error"
,
"Request body is empty"
)
return
}
if
isMultipartImagesContentType
(
c
.
GetHeader
(
"Content-Type"
))
{
setOpsRequestContext
(
c
,
""
,
false
,
nil
)
}
else
{
setOpsRequestContext
(
c
,
""
,
false
,
body
)
}
parsed
,
err
:=
h
.
gatewayService
.
ParseOpenAIImagesRequest
(
c
,
body
)
if
err
!=
nil
{
h
.
errorResponse
(
c
,
http
.
StatusBadRequest
,
"invalid_request_error"
,
err
.
Error
())
return
}
reqLog
=
reqLog
.
With
(
zap
.
String
(
"model"
,
parsed
.
Model
),
zap
.
Bool
(
"stream"
,
parsed
.
Stream
),
zap
.
Bool
(
"multipart"
,
parsed
.
Multipart
),
zap
.
String
(
"capability"
,
string
(
parsed
.
RequiredCapability
)),
)
if
parsed
.
Multipart
{
setOpsRequestContext
(
c
,
parsed
.
Model
,
parsed
.
Stream
,
nil
)
}
else
{
setOpsRequestContext
(
c
,
parsed
.
Model
,
parsed
.
Stream
,
body
)
}
setOpsEndpointContext
(
c
,
""
,
int16
(
service
.
RequestTypeFromLegacy
(
parsed
.
Stream
,
false
)))
channelMapping
,
_
:=
h
.
gatewayService
.
ResolveChannelMappingAndRestrict
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
parsed
.
Model
)
if
h
.
errorPassthroughService
!=
nil
{
service
.
BindErrorPassthroughService
(
c
,
h
.
errorPassthroughService
)
}
subscription
,
_
:=
middleware2
.
GetSubscriptionFromContext
(
c
)
service
.
SetOpsLatencyMs
(
c
,
service
.
OpsAuthLatencyMsKey
,
time
.
Since
(
requestStart
)
.
Milliseconds
())
routingStart
:=
time
.
Now
()
userReleaseFunc
,
acquired
:=
h
.
acquireResponsesUserSlot
(
c
,
subject
.
UserID
,
subject
.
Concurrency
,
parsed
.
Stream
,
&
streamStarted
,
reqLog
)
if
!
acquired
{
return
}
if
userReleaseFunc
!=
nil
{
defer
userReleaseFunc
()
}
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"openai.images.billing_eligibility_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
h
.
handleStreamingAwareError
(
c
,
status
,
code
,
message
,
streamStarted
)
return
}
sessionHash
:=
""
if
parsed
.
Multipart
{
sessionHash
=
h
.
gatewayService
.
GenerateSessionHashWithFallback
(
c
,
nil
,
parsed
.
StickySessionSeed
())
}
else
{
sessionHash
=
h
.
gatewayService
.
GenerateSessionHash
(
c
,
body
)
}
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
sameAccountRetryCount
:=
make
(
map
[
int64
]
int
)
var
lastFailoverErr
*
service
.
UpstreamFailoverError
for
{
reqLog
.
Debug
(
"openai.images.account_selecting"
,
zap
.
Int
(
"excluded_account_count"
,
len
(
failedAccountIDs
)))
selection
,
scheduleDecision
,
err
:=
h
.
gatewayService
.
SelectAccountWithSchedulerForImages
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionHash
,
parsed
.
Model
,
failedAccountIDs
,
parsed
.
RequiredCapability
,
)
if
err
!=
nil
{
reqLog
.
Warn
(
"openai.images.account_select_failed"
,
zap
.
Error
(
err
),
zap
.
Int
(
"excluded_account_count"
,
len
(
failedAccountIDs
)),
)
if
len
(
failedAccountIDs
)
==
0
{
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available compatible accounts"
,
streamStarted
)
return
}
if
lastFailoverErr
!=
nil
{
h
.
handleFailoverExhausted
(
c
,
lastFailoverErr
,
streamStarted
)
}
else
{
h
.
handleFailoverExhaustedSimple
(
c
,
502
,
streamStarted
)
}
return
}
if
selection
==
nil
||
selection
.
Account
==
nil
{
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available compatible accounts"
,
streamStarted
)
return
}
reqLog
.
Debug
(
"openai.images.account_schedule_decision"
,
zap
.
String
(
"layer"
,
scheduleDecision
.
Layer
),
zap
.
Bool
(
"sticky_session_hit"
,
scheduleDecision
.
StickySessionHit
),
zap
.
Int
(
"candidate_count"
,
scheduleDecision
.
CandidateCount
),
zap
.
Int
(
"top_k"
,
scheduleDecision
.
TopK
),
zap
.
Int64
(
"latency_ms"
,
scheduleDecision
.
LatencyMs
),
zap
.
Float64
(
"load_skew"
,
scheduleDecision
.
LoadSkew
),
)
account
:=
selection
.
Account
sessionHash
=
ensureOpenAIPoolModeSessionHash
(
sessionHash
,
account
)
reqLog
.
Debug
(
"openai.images.account_selected"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
String
(
"account_name"
,
account
.
Name
))
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
accountReleaseFunc
,
acquired
:=
h
.
acquireResponsesAccountSlot
(
c
,
apiKey
.
GroupID
,
sessionHash
,
selection
,
parsed
.
Stream
,
&
streamStarted
,
reqLog
)
if
!
acquired
{
return
}
service
.
SetOpsLatencyMs
(
c
,
service
.
OpsRoutingLatencyMsKey
,
time
.
Since
(
routingStart
)
.
Milliseconds
())
forwardStart
:=
time
.
Now
()
result
,
err
:=
h
.
gatewayService
.
ForwardImages
(
c
.
Request
.
Context
(),
c
,
account
,
body
,
parsed
,
channelMapping
.
MappedModel
)
forwardDurationMs
:=
time
.
Since
(
forwardStart
)
.
Milliseconds
()
if
accountReleaseFunc
!=
nil
{
accountReleaseFunc
()
}
upstreamLatencyMs
,
_
:=
getContextInt64
(
c
,
service
.
OpsUpstreamLatencyMsKey
)
responseLatencyMs
:=
forwardDurationMs
if
upstreamLatencyMs
>
0
&&
forwardDurationMs
>
upstreamLatencyMs
{
responseLatencyMs
=
forwardDurationMs
-
upstreamLatencyMs
}
service
.
SetOpsLatencyMs
(
c
,
service
.
OpsResponseLatencyMsKey
,
responseLatencyMs
)
if
err
==
nil
&&
result
!=
nil
&&
result
.
FirstTokenMs
!=
nil
{
service
.
SetOpsLatencyMs
(
c
,
service
.
OpsTimeToFirstTokenMsKey
,
int64
(
*
result
.
FirstTokenMs
))
}
if
err
!=
nil
{
var
failoverErr
*
service
.
UpstreamFailoverError
if
errors
.
As
(
err
,
&
failoverErr
)
{
h
.
gatewayService
.
ReportOpenAIAccountScheduleResult
(
account
.
ID
,
false
,
nil
)
if
failoverErr
.
RetryableOnSameAccount
{
retryLimit
:=
account
.
GetPoolModeRetryCount
()
if
sameAccountRetryCount
[
account
.
ID
]
<
retryLimit
{
sameAccountRetryCount
[
account
.
ID
]
++
reqLog
.
Warn
(
"openai.images.pool_mode_same_account_retry"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int
(
"upstream_status"
,
failoverErr
.
StatusCode
),
zap
.
Int
(
"retry_limit"
,
retryLimit
),
zap
.
Int
(
"retry_count"
,
sameAccountRetryCount
[
account
.
ID
]),
)
select
{
case
<-
c
.
Request
.
Context
()
.
Done
()
:
return
case
<-
time
.
After
(
sameAccountRetryDelay
)
:
}
continue
}
}
h
.
gatewayService
.
RecordOpenAIAccountSwitch
()
failedAccountIDs
[
account
.
ID
]
=
struct
{}{}
lastFailoverErr
=
failoverErr
if
switchCount
>=
maxAccountSwitches
{
h
.
handleFailoverExhausted
(
c
,
failoverErr
,
streamStarted
)
return
}
switchCount
++
reqLog
.
Warn
(
"openai.images.upstream_failover_switching"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int
(
"upstream_status"
,
failoverErr
.
StatusCode
),
zap
.
Int
(
"switch_count"
,
switchCount
),
zap
.
Int
(
"max_switches"
,
maxAccountSwitches
),
)
continue
}
h
.
gatewayService
.
ReportOpenAIAccountScheduleResult
(
account
.
ID
,
false
,
nil
)
wroteFallback
:=
h
.
ensureForwardErrorResponse
(
c
,
streamStarted
)
fields
:=
[]
zap
.
Field
{
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Bool
(
"fallback_error_response_written"
,
wroteFallback
),
zap
.
Error
(
err
),
}
if
shouldLogOpenAIForwardFailureAsWarn
(
c
,
wroteFallback
)
{
reqLog
.
Warn
(
"openai.images.forward_failed"
,
fields
...
)
return
}
reqLog
.
Error
(
"openai.images.forward_failed"
,
fields
...
)
return
}
if
result
!=
nil
{
if
account
.
Type
==
service
.
AccountTypeOAuth
{
h
.
gatewayService
.
UpdateCodexUsageSnapshotFromHeaders
(
c
.
Request
.
Context
(),
account
.
ID
,
result
.
ResponseHeaders
)
}
h
.
gatewayService
.
ReportOpenAIAccountScheduleResult
(
account
.
ID
,
true
,
result
.
FirstTokenMs
)
}
else
{
h
.
gatewayService
.
ReportOpenAIAccountScheduleResult
(
account
.
ID
,
true
,
nil
)
}
userAgent
:=
c
.
GetHeader
(
"User-Agent"
)
clientIP
:=
ip
.
GetClientIP
(
c
)
requestPayloadHash
:=
service
.
HashUsageRequestPayload
(
body
)
if
parsed
.
Multipart
{
requestPayloadHash
=
service
.
HashUsageRequestPayload
([]
byte
(
parsed
.
StickySessionSeed
()))
}
h
.
submitUsageRecordTask
(
func
(
ctx
context
.
Context
)
{
if
err
:=
h
.
gatewayService
.
RecordUsage
(
ctx
,
&
service
.
OpenAIRecordUsageInput
{
Result
:
result
,
APIKey
:
apiKey
,
User
:
apiKey
.
User
,
Account
:
account
,
Subscription
:
subscription
,
InboundEndpoint
:
GetInboundEndpoint
(
c
),
UpstreamEndpoint
:
GetUpstreamEndpoint
(
c
,
account
.
Platform
),
UserAgent
:
userAgent
,
IPAddress
:
clientIP
,
RequestPayloadHash
:
requestPayloadHash
,
APIKeyService
:
h
.
apiKeyService
,
ChannelUsageFields
:
channelMapping
.
ToUsageFields
(
parsed
.
Model
,
result
.
UpstreamModel
),
});
err
!=
nil
{
logger
.
L
()
.
With
(
zap
.
String
(
"component"
,
"handler.openai_gateway.images"
),
zap
.
Int64
(
"user_id"
,
subject
.
UserID
),
zap
.
Int64
(
"api_key_id"
,
apiKey
.
ID
),
zap
.
Any
(
"group_id"
,
apiKey
.
GroupID
),
zap
.
String
(
"model"
,
parsed
.
Model
),
zap
.
Int64
(
"account_id"
,
account
.
ID
),
)
.
Error
(
"openai.images.record_usage_failed"
,
zap
.
Error
(
err
))
}
})
reqLog
.
Debug
(
"openai.images.request_completed"
,
zap
.
Int64
(
"account_id"
,
account
.
ID
),
zap
.
Int
(
"switch_count"
,
switchCount
),
)
return
}
}
func
isMultipartImagesContentType
(
contentType
string
)
bool
{
return
strings
.
HasPrefix
(
strings
.
ToLower
(
strings
.
TrimSpace
(
contentType
)),
"multipart/form-data"
)
}
backend/internal/handler/ops_error_logger.go
View file @
32107b4f
...
...
@@ -1068,7 +1068,7 @@ func guessPlatformFromPath(path string) string {
return
service
.
PlatformAntigravity
case
strings
.
HasPrefix
(
p
,
"/v1beta/"
)
:
return
service
.
PlatformGemini
case
strings
.
Contains
(
p
,
"/responses"
)
:
case
strings
.
Contains
(
p
,
"/responses"
)
,
strings
.
Contains
(
p
,
"/images/"
)
:
return
service
.
PlatformOpenAI
default
:
return
""
...
...
backend/internal/pkg/openai/constants.go
View file @
32107b4f
...
...
@@ -20,6 +20,7 @@ var DefaultModels = []Model{
{
ID
:
"gpt-5.3-codex"
,
Object
:
"model"
,
Created
:
1735689600
,
OwnedBy
:
"openai"
,
Type
:
"model"
,
DisplayName
:
"GPT-5.3 Codex"
},
{
ID
:
"gpt-5.3-codex-spark"
,
Object
:
"model"
,
Created
:
1735689600
,
OwnedBy
:
"openai"
,
Type
:
"model"
,
DisplayName
:
"GPT-5.3 Codex Spark"
},
{
ID
:
"gpt-5.2"
,
Object
:
"model"
,
Created
:
1733875200
,
OwnedBy
:
"openai"
,
Type
:
"model"
,
DisplayName
:
"GPT-5.2"
},
{
ID
:
"gpt-image-2"
,
Object
:
"model"
,
Created
:
1738368000
,
OwnedBy
:
"openai"
,
Type
:
"model"
,
DisplayName
:
"GPT Image 2"
},
}
// DefaultModelIDs returns the default model ID list
...
...
backend/internal/server/middleware/security_headers.go
View file @
32107b4f
...
...
@@ -96,7 +96,8 @@ func isAPIRoutePath(c *gin.Context) bool {
return
strings
.
HasPrefix
(
path
,
"/v1/"
)
||
strings
.
HasPrefix
(
path
,
"/v1beta/"
)
||
strings
.
HasPrefix
(
path
,
"/antigravity/"
)
||
strings
.
HasPrefix
(
path
,
"/responses"
)
strings
.
HasPrefix
(
path
,
"/responses"
)
||
strings
.
HasPrefix
(
path
,
"/images"
)
}
// enhanceCSPPolicy ensures the CSP policy includes nonce support, Cloudflare Insights,
...
...
backend/internal/server/routes/gateway.go
View file @
32107b4f
...
...
@@ -88,6 +88,30 @@ func RegisterGatewayRoutes(
}
h
.
Gateway
.
ChatCompletions
(
c
)
})
gateway
.
POST
(
"/images/generations"
,
func
(
c
*
gin
.
Context
)
{
if
getGroupPlatform
(
c
)
!=
service
.
PlatformOpenAI
{
c
.
JSON
(
http
.
StatusNotFound
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"not_found_error"
,
"message"
:
"Images API is not supported for this platform"
,
},
})
return
}
h
.
OpenAIGateway
.
Images
(
c
)
})
gateway
.
POST
(
"/images/edits"
,
func
(
c
*
gin
.
Context
)
{
if
getGroupPlatform
(
c
)
!=
service
.
PlatformOpenAI
{
c
.
JSON
(
http
.
StatusNotFound
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"not_found_error"
,
"message"
:
"Images API is not supported for this platform"
,
},
})
return
}
h
.
OpenAIGateway
.
Images
(
c
)
})
}
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
...
...
@@ -124,6 +148,30 @@ func RegisterGatewayRoutes(
}
h
.
Gateway
.
ChatCompletions
(
c
)
})
r
.
POST
(
"/images/generations"
,
bodyLimit
,
clientRequestID
,
opsErrorLogger
,
endpointNorm
,
gin
.
HandlerFunc
(
apiKeyAuth
),
requireGroupAnthropic
,
func
(
c
*
gin
.
Context
)
{
if
getGroupPlatform
(
c
)
!=
service
.
PlatformOpenAI
{
c
.
JSON
(
http
.
StatusNotFound
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"not_found_error"
,
"message"
:
"Images API is not supported for this platform"
,
},
})
return
}
h
.
OpenAIGateway
.
Images
(
c
)
})
r
.
POST
(
"/images/edits"
,
bodyLimit
,
clientRequestID
,
opsErrorLogger
,
endpointNorm
,
gin
.
HandlerFunc
(
apiKeyAuth
),
requireGroupAnthropic
,
func
(
c
*
gin
.
Context
)
{
if
getGroupPlatform
(
c
)
!=
service
.
PlatformOpenAI
{
c
.
JSON
(
http
.
StatusNotFound
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"not_found_error"
,
"message"
:
"Images API is not supported for this platform"
,
},
})
return
}
h
.
OpenAIGateway
.
Images
(
c
)
})
// Antigravity 模型列表
r
.
GET
(
"/antigravity/models"
,
gin
.
HandlerFunc
(
apiKeyAuth
),
requireGroupAnthropic
,
h
.
Gateway
.
AntigravityModels
)
...
...
backend/internal/server/routes/gateway_test.go
View file @
32107b4f
...
...
@@ -9,6 +9,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
servermiddleware
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
...
...
@@ -24,6 +25,11 @@ func newGatewayRoutesTestRouter() *gin.Engine {
OpenAIGateway
:
&
handler
.
OpenAIGatewayHandler
{},
},
servermiddleware
.
APIKeyAuthMiddleware
(
func
(
c
*
gin
.
Context
)
{
groupID
:=
int64
(
1
)
c
.
Set
(
string
(
servermiddleware
.
ContextKeyAPIKey
),
&
service
.
APIKey
{
GroupID
:
&
groupID
,
Group
:
&
service
.
Group
{
Platform
:
service
.
PlatformOpenAI
},
})
c
.
Next
()
}),
nil
,
...
...
@@ -48,3 +54,21 @@ func TestGatewayRoutesOpenAIResponsesCompactPathIsRegistered(t *testing.T) {
require
.
NotEqual
(
t
,
http
.
StatusNotFound
,
w
.
Code
,
"path=%s should hit OpenAI responses handler"
,
path
)
}
}
func
TestGatewayRoutesOpenAIImagesPathsAreRegistered
(
t
*
testing
.
T
)
{
router
:=
newGatewayRoutesTestRouter
()
for
_
,
path
:=
range
[]
string
{
"/v1/images/generations"
,
"/v1/images/edits"
,
"/images/generations"
,
"/images/edits"
,
}
{
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
path
,
strings
.
NewReader
(
`{"model":"gpt-image-2","prompt":"draw a cat"}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
w
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
w
,
req
)
require
.
NotEqual
(
t
,
http
.
StatusNotFound
,
w
.
Code
,
"path=%s should hit OpenAI images handler"
,
path
)
}
}
backend/internal/service/account.go
View file @
32107b4f
...
...
@@ -911,6 +911,34 @@ func (a *Account) GetChatGPTAccountID() string {
return
a
.
GetCredential
(
"chatgpt_account_id"
)
}
func
(
a
*
Account
)
GetOpenAIDeviceID
()
string
{
if
!
a
.
IsOpenAIOAuth
()
{
return
""
}
return
strings
.
TrimSpace
(
a
.
GetExtraString
(
"openai_device_id"
))
}
func
(
a
*
Account
)
GetOpenAISessionID
()
string
{
if
!
a
.
IsOpenAIOAuth
()
{
return
""
}
return
strings
.
TrimSpace
(
a
.
GetExtraString
(
"openai_session_id"
))
}
func
(
a
*
Account
)
SupportsOpenAIImageCapability
(
capability
OpenAIImagesCapability
)
bool
{
if
!
a
.
IsOpenAI
()
{
return
false
}
switch
capability
{
case
OpenAIImagesCapabilityBasic
:
return
a
.
Type
==
AccountTypeOAuth
||
a
.
Type
==
AccountTypeAPIKey
case
OpenAIImagesCapabilityNative
:
return
a
.
Type
==
AccountTypeAPIKey
default
:
return
true
}
}
func
(
a
*
Account
)
GetChatGPTUserID
()
string
{
if
!
a
.
IsOpenAIOAuth
()
{
return
""
...
...
backend/internal/service/model_pricing_resolver.go
View file @
32107b4f
...
...
@@ -61,6 +61,25 @@ type PricingInput struct {
// 1. 获取基础定价(LiteLLM → Fallback)
// 2. 如果指定了 GroupID,查找渠道定价并覆盖
func
(
r
*
ModelPricingResolver
)
Resolve
(
ctx
context
.
Context
,
input
PricingInput
)
*
ResolvedPricing
{
var
chPricing
*
ChannelModelPricing
if
input
.
GroupID
!=
nil
&&
r
.
channelService
!=
nil
{
chPricing
=
r
.
channelService
.
GetChannelModelPricing
(
ctx
,
*
input
.
GroupID
,
input
.
Model
)
if
chPricing
!=
nil
{
mode
:=
chPricing
.
BillingMode
if
mode
==
""
{
mode
=
BillingModeToken
}
if
mode
==
BillingModePerRequest
||
mode
==
BillingModeImage
{
resolved
:=
&
ResolvedPricing
{
Mode
:
mode
,
Source
:
PricingSourceChannel
,
}
r
.
applyRequestTierOverrides
(
chPricing
,
resolved
)
return
resolved
}
}
}
// 1. 获取基础定价
basePricing
,
source
:=
r
.
resolveBasePricing
(
input
.
Model
)
...
...
@@ -72,7 +91,10 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
}
// 2. 如果有 GroupID,尝试渠道覆盖
if
input
.
GroupID
!=
nil
{
if
chPricing
!=
nil
{
resolved
.
Source
=
PricingSourceChannel
r
.
applyTokenOverrides
(
chPricing
,
resolved
)
}
else
if
input
.
GroupID
!=
nil
{
r
.
applyChannelOverrides
(
ctx
,
*
input
.
GroupID
,
input
.
Model
,
resolved
)
}
...
...
backend/internal/service/openai_account_scheduler.go
View file @
32107b4f
...
...
@@ -38,13 +38,14 @@ var openAIAdvancedSchedulerSettingCache atomic.Value // *cachedOpenAIAdvancedSch
var
openAIAdvancedSchedulerSettingSF
singleflight
.
Group
type
OpenAIAccountScheduleRequest
struct
{
GroupID
*
int64
SessionHash
string
StickyAccountID
int64
PreviousResponseID
string
RequestedModel
string
RequiredTransport
OpenAIUpstreamTransport
ExcludedIDs
map
[
int64
]
struct
{}
GroupID
*
int64
SessionHash
string
StickyAccountID
int64
PreviousResponseID
string
RequestedModel
string
RequiredTransport
OpenAIUpstreamTransport
RequiredImageCapability
OpenAIImagesCapability
ExcludedIDs
map
[
int64
]
struct
{}
}
type
OpenAIAccountScheduleDecision
struct
{
...
...
@@ -340,7 +341,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
_
=
s
.
service
.
deleteStickySessionAccountID
(
ctx
,
req
.
GroupID
,
sessionHash
)
return
nil
,
nil
}
if
req
.
RequestedModel
!=
""
&&
!
account
.
IsModelSupported
(
req
.
RequestedModel
)
{
if
!
s
.
isAccountRequestCompatible
(
account
,
req
)
{
return
nil
,
nil
}
if
!
s
.
isAccountTransportCompatible
(
account
,
req
.
RequiredTransport
)
{
...
...
@@ -616,7 +617,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
fmt
.
Sprintf
(
"Privacy not set, required by group [%s]"
,
schedGroup
.
Name
))
continue
}
if
req
.
RequestedModel
!=
""
&&
!
account
.
IsModelSupported
(
req
.
RequestedModel
)
{
if
!
s
.
isAccountRequestCompatible
(
account
,
req
)
{
continue
}
if
!
s
.
isAccountTransportCompatible
(
account
,
req
.
RequiredTransport
)
{
...
...
@@ -722,11 +723,11 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
for
i
:=
0
;
i
<
len
(
selectionOrder
);
i
++
{
candidate
:=
selectionOrder
[
i
]
fresh
:=
s
.
service
.
resolveFreshSchedulableOpenAIAccount
(
ctx
,
candidate
.
account
,
req
.
RequestedModel
)
if
fresh
==
nil
||
!
s
.
isAccountTransportCompatible
(
fresh
,
req
.
RequiredTransport
)
{
if
fresh
==
nil
||
!
s
.
isAccountTransportCompatible
(
fresh
,
req
.
RequiredTransport
)
||
!
s
.
isAccountRequestCompatible
(
fresh
,
req
)
{
continue
}
fresh
=
s
.
service
.
recheckSelectedOpenAIAccountFromDB
(
ctx
,
fresh
,
req
.
RequestedModel
)
if
fresh
==
nil
||
!
s
.
isAccountTransportCompatible
(
fresh
,
req
.
RequiredTransport
)
{
if
fresh
==
nil
||
!
s
.
isAccountTransportCompatible
(
fresh
,
req
.
RequiredTransport
)
||
!
s
.
isAccountRequestCompatible
(
fresh
,
req
)
{
continue
}
result
,
acquireErr
:=
s
.
service
.
tryAcquireAccountSlot
(
ctx
,
fresh
.
ID
,
fresh
.
Concurrency
)
...
...
@@ -749,7 +750,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
// WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。
for
_
,
candidate
:=
range
selectionOrder
{
fresh
:=
s
.
service
.
resolveFreshSchedulableOpenAIAccount
(
ctx
,
candidate
.
account
,
req
.
RequestedModel
)
if
fresh
==
nil
||
!
s
.
isAccountTransportCompatible
(
fresh
,
req
.
RequiredTransport
)
{
if
fresh
==
nil
||
!
s
.
isAccountTransportCompatible
(
fresh
,
req
.
RequiredTransport
)
||
!
s
.
isAccountRequestCompatible
(
fresh
,
req
)
{
continue
}
return
&
AccountSelectionResult
{
...
...
@@ -776,6 +777,16 @@ func (s *defaultOpenAIAccountScheduler) isAccountTransportCompatible(account *Ac
return
s
.
service
.
isOpenAIAccountTransportCompatible
(
account
,
requiredTransport
)
}
func
(
s
*
defaultOpenAIAccountScheduler
)
isAccountRequestCompatible
(
account
*
Account
,
req
OpenAIAccountScheduleRequest
)
bool
{
if
account
==
nil
{
return
false
}
if
req
.
RequestedModel
!=
""
&&
!
account
.
IsModelSupported
(
req
.
RequestedModel
)
{
return
false
}
return
account
.
SupportsOpenAIImageCapability
(
req
.
RequiredImageCapability
)
}
func
(
s
*
defaultOpenAIAccountScheduler
)
ReportResult
(
accountID
int64
,
success
bool
,
firstTokenMs
*
int
)
{
if
s
==
nil
||
s
.
stats
==
nil
{
return
...
...
@@ -894,14 +905,59 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler(
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
requiredTransport
OpenAIUpstreamTransport
,
)
(
*
AccountSelectionResult
,
OpenAIAccountScheduleDecision
,
error
)
{
return
s
.
selectAccountWithScheduler
(
ctx
,
groupID
,
previousResponseID
,
sessionHash
,
requestedModel
,
excludedIDs
,
requiredTransport
,
""
)
}
func
(
s
*
OpenAIGatewayService
)
SelectAccountWithSchedulerForImages
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
requiredCapability
OpenAIImagesCapability
,
)
(
*
AccountSelectionResult
,
OpenAIAccountScheduleDecision
,
error
)
{
return
s
.
selectAccountWithScheduler
(
ctx
,
groupID
,
""
,
sessionHash
,
requestedModel
,
excludedIDs
,
OpenAIUpstreamTransportHTTPSSE
,
requiredCapability
)
}
func
(
s
*
OpenAIGatewayService
)
selectAccountWithScheduler
(
ctx
context
.
Context
,
groupID
*
int64
,
previousResponseID
string
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
requiredTransport
OpenAIUpstreamTransport
,
requiredImageCapability
OpenAIImagesCapability
,
)
(
*
AccountSelectionResult
,
OpenAIAccountScheduleDecision
,
error
)
{
decision
:=
OpenAIAccountScheduleDecision
{}
scheduler
:=
s
.
getOpenAIAccountScheduler
(
ctx
)
if
scheduler
==
nil
{
decision
.
Layer
=
openAIAccountScheduleLayerLoadBalance
if
requiredTransport
==
OpenAIUpstreamTransportAny
||
requiredTransport
==
OpenAIUpstreamTransportHTTPSSE
{
selection
,
err
:=
s
.
SelectAccountWithLoadAwareness
(
ctx
,
groupID
,
sessionHash
,
requestedModel
,
excludedIDs
)
return
selection
,
decision
,
err
effectiveExcludedIDs
:=
cloneExcludedAccountIDs
(
excludedIDs
)
for
{
selection
,
err
:=
s
.
SelectAccountWithLoadAwareness
(
ctx
,
groupID
,
sessionHash
,
requestedModel
,
effectiveExcludedIDs
)
if
err
!=
nil
{
return
nil
,
decision
,
err
}
if
selection
==
nil
||
selection
.
Account
==
nil
{
return
selection
,
decision
,
nil
}
if
selection
.
Account
.
SupportsOpenAIImageCapability
(
requiredImageCapability
)
{
return
selection
,
decision
,
nil
}
if
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
effectiveExcludedIDs
==
nil
{
effectiveExcludedIDs
=
make
(
map
[
int64
]
struct
{})
}
if
_
,
exists
:=
effectiveExcludedIDs
[
selection
.
Account
.
ID
];
exists
{
return
nil
,
decision
,
ErrNoAvailableAccounts
}
effectiveExcludedIDs
[
selection
.
Account
.
ID
]
=
struct
{}{}
}
}
effectiveExcludedIDs
:=
cloneExcludedAccountIDs
(
excludedIDs
)
...
...
@@ -937,13 +993,14 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler(
}
return
scheduler
.
Select
(
ctx
,
OpenAIAccountScheduleRequest
{
GroupID
:
groupID
,
SessionHash
:
sessionHash
,
StickyAccountID
:
stickyAccountID
,
PreviousResponseID
:
previousResponseID
,
RequestedModel
:
requestedModel
,
RequiredTransport
:
requiredTransport
,
ExcludedIDs
:
excludedIDs
,
GroupID
:
groupID
,
SessionHash
:
sessionHash
,
StickyAccountID
:
stickyAccountID
,
PreviousResponseID
:
previousResponseID
,
RequestedModel
:
requestedModel
,
RequiredTransport
:
requiredTransport
,
RequiredImageCapability
:
requiredImageCapability
,
ExcludedIDs
:
excludedIDs
,
})
}
...
...
backend/internal/service/openai_gateway_record_usage_test.go
View file @
32107b4f
...
...
@@ -1070,3 +1070,31 @@ func TestOpenAIGatewayServiceRecordUsage_SimpleModeSkipsBillingAfterPersist(t *t
require
.
Equal
(
t
,
0
,
userRepo
.
deductCalls
)
require
.
Equal
(
t
,
0
,
subRepo
.
incrementCalls
)
}
func
TestOpenAIGatewayServiceRecordUsage_ImageOnlyUsageStillPersists
(
t
*
testing
.
T
)
{
usageRepo
:=
&
openAIRecordUsageLogRepoStub
{
inserted
:
true
}
userRepo
:=
&
openAIRecordUsageUserRepoStub
{}
subRepo
:=
&
openAIRecordUsageSubRepoStub
{}
svc
:=
newOpenAIRecordUsageServiceForTest
(
usageRepo
,
userRepo
,
subRepo
,
nil
)
err
:=
svc
.
RecordUsage
(
context
.
Background
(),
&
OpenAIRecordUsageInput
{
Result
:
&
OpenAIForwardResult
{
RequestID
:
"resp_image_only_usage"
,
Model
:
"gpt-image-2"
,
ImageCount
:
2
,
ImageSize
:
"1K"
,
Duration
:
time
.
Second
,
},
APIKey
:
&
APIKey
{
ID
:
1007
},
User
:
&
User
{
ID
:
2007
},
Account
:
&
Account
{
ID
:
3007
},
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
)
require
.
Equal
(
t
,
2
,
usageRepo
.
lastLog
.
ImageCount
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
.
ImageSize
)
require
.
Equal
(
t
,
"1K"
,
*
usageRepo
.
lastLog
.
ImageSize
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
.
BillingMode
)
require
.
Equal
(
t
,
string
(
BillingModeImage
),
*
usageRepo
.
lastLog
.
BillingMode
)
}
backend/internal/service/openai_gateway_service.go
View file @
32107b4f
...
...
@@ -233,6 +233,8 @@ type OpenAIForwardResult struct {
ResponseHeaders
http
.
Header
Duration
time
.
Duration
FirstTokenMs
*
int
ImageCount
int
ImageSize
string
}
type
OpenAIWSRetryMetricsSnapshot
struct
{
...
...
@@ -3889,6 +3891,7 @@ func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsag
usage
.
InputTokens
=
int
(
gjson
.
GetBytes
(
data
,
"response.usage.input_tokens"
)
.
Int
())
usage
.
OutputTokens
=
int
(
gjson
.
GetBytes
(
data
,
"response.usage.output_tokens"
)
.
Int
())
usage
.
CacheReadInputTokens
=
int
(
gjson
.
GetBytes
(
data
,
"response.usage.input_tokens_details.cached_tokens"
)
.
Int
())
usage
.
ImageOutputTokens
=
int
(
gjson
.
GetBytes
(
data
,
"response.usage.output_tokens_details.image_tokens"
)
.
Int
())
}
func
extractOpenAIUsageFromJSONBytes
(
body
[]
byte
)
(
OpenAIUsage
,
bool
)
{
...
...
@@ -3900,11 +3903,13 @@ func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) {
"usage.input_tokens"
,
"usage.output_tokens"
,
"usage.input_tokens_details.cached_tokens"
,
"usage.output_tokens_details.image_tokens"
,
)
return
OpenAIUsage
{
InputTokens
:
int
(
values
[
0
]
.
Int
()),
OutputTokens
:
int
(
values
[
1
]
.
Int
()),
CacheReadInputTokens
:
int
(
values
[
2
]
.
Int
()),
ImageOutputTokens
:
int
(
values
[
3
]
.
Int
()),
},
true
}
...
...
@@ -4397,7 +4402,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
// 跳过所有 token 均为零的用量记录——上游未返回 usage 时不应写入数据库
if
result
.
Usage
.
InputTokens
==
0
&&
result
.
Usage
.
OutputTokens
==
0
&&
result
.
Usage
.
CacheCreationInputTokens
==
0
&&
result
.
Usage
.
CacheReadInputTokens
==
0
{
result
.
Usage
.
CacheCreationInputTokens
==
0
&&
result
.
Usage
.
CacheReadInputTokens
==
0
&&
result
.
Usage
.
ImageOutputTokens
==
0
&&
result
.
ImageCount
==
0
{
return
nil
}
...
...
@@ -4451,21 +4457,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if
result
.
ServiceTier
!=
nil
{
serviceTier
=
strings
.
TrimSpace
(
*
result
.
ServiceTier
)
}
if
s
.
resolver
!=
nil
&&
apiKey
.
Group
!=
nil
{
gid
:=
apiKey
.
Group
.
ID
cost
,
err
=
s
.
billingService
.
CalculateCostUnified
(
CostInput
{
Ctx
:
ctx
,
Model
:
billingModel
,
GroupID
:
&
gid
,
Tokens
:
tokens
,
RequestCount
:
1
,
RateMultiplier
:
multiplier
,
ServiceTier
:
serviceTier
,
Resolver
:
s
.
resolver
,
})
}
else
{
cost
,
err
=
s
.
billingService
.
CalculateCostWithServiceTier
(
billingModel
,
tokens
,
multiplier
,
serviceTier
)
}
cost
,
err
=
s
.
calculateOpenAIRecordUsageCost
(
ctx
,
result
,
apiKey
,
billingModel
,
multiplier
,
tokens
,
serviceTier
)
if
err
!=
nil
{
cost
=
&
CostBreakdown
{
ActualCost
:
0
}
}
...
...
@@ -4505,6 +4497,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
CacheReadTokens
:
result
.
Usage
.
CacheReadInputTokens
,
ImageOutputTokens
:
result
.
Usage
.
ImageOutputTokens
,
ImageCount
:
result
.
ImageCount
,
ImageSize
:
optionalTrimmedStringPtr
(
result
.
ImageSize
),
}
if
cost
!=
nil
{
usageLog
.
InputCost
=
cost
.
InputCost
...
...
@@ -4530,6 +4524,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if
cost
!=
nil
&&
cost
.
BillingMode
!=
""
{
billingMode
:=
cost
.
BillingMode
usageLog
.
BillingMode
=
&
billingMode
}
else
if
result
.
ImageCount
>
0
{
billingMode
:=
string
(
BillingModeImage
)
usageLog
.
BillingMode
=
&
billingMode
}
else
{
billingMode
:=
string
(
BillingModeToken
)
usageLog
.
BillingMode
=
&
billingMode
...
...
@@ -4589,6 +4586,125 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
return
nil
}
func
(
s
*
OpenAIGatewayService
)
calculateOpenAIRecordUsageCost
(
ctx
context
.
Context
,
result
*
OpenAIForwardResult
,
apiKey
*
APIKey
,
billingModel
string
,
multiplier
float64
,
tokens
UsageTokens
,
serviceTier
string
,
)
(
*
CostBreakdown
,
error
)
{
if
result
!=
nil
&&
result
.
ImageCount
>
0
{
if
hasOpenAIImageUsageTokens
(
result
)
{
cost
,
err
:=
s
.
calculateOpenAIImageTokenCost
(
ctx
,
apiKey
,
billingModel
,
multiplier
,
tokens
,
serviceTier
,
result
.
ImageSize
)
if
err
==
nil
{
return
cost
,
nil
}
}
return
s
.
calculateOpenAIImageCost
(
ctx
,
billingModel
,
apiKey
,
result
,
multiplier
),
nil
}
if
s
.
resolver
!=
nil
&&
apiKey
.
Group
!=
nil
{
gid
:=
apiKey
.
Group
.
ID
return
s
.
billingService
.
CalculateCostUnified
(
CostInput
{
Ctx
:
ctx
,
Model
:
billingModel
,
GroupID
:
&
gid
,
Tokens
:
tokens
,
RequestCount
:
1
,
RateMultiplier
:
multiplier
,
ServiceTier
:
serviceTier
,
Resolver
:
s
.
resolver
,
})
}
return
s
.
billingService
.
CalculateCostWithServiceTier
(
billingModel
,
tokens
,
multiplier
,
serviceTier
)
}
func
(
s
*
OpenAIGatewayService
)
calculateOpenAIImageTokenCost
(
ctx
context
.
Context
,
apiKey
*
APIKey
,
billingModel
string
,
multiplier
float64
,
tokens
UsageTokens
,
serviceTier
string
,
sizeTier
string
,
)
(
*
CostBreakdown
,
error
)
{
if
s
.
resolver
!=
nil
&&
apiKey
.
Group
!=
nil
{
gid
:=
apiKey
.
Group
.
ID
return
s
.
billingService
.
CalculateCostUnified
(
CostInput
{
Ctx
:
ctx
,
Model
:
billingModel
,
GroupID
:
&
gid
,
Tokens
:
tokens
,
RequestCount
:
1
,
SizeTier
:
sizeTier
,
RateMultiplier
:
multiplier
,
ServiceTier
:
serviceTier
,
Resolver
:
s
.
resolver
,
})
}
return
s
.
billingService
.
CalculateCostWithServiceTier
(
billingModel
,
tokens
,
multiplier
,
serviceTier
)
}
func
(
s
*
OpenAIGatewayService
)
calculateOpenAIImageCost
(
ctx
context
.
Context
,
billingModel
string
,
apiKey
*
APIKey
,
result
*
OpenAIForwardResult
,
multiplier
float64
,
)
*
CostBreakdown
{
if
resolved
:=
s
.
resolveOpenAIChannelPricing
(
ctx
,
billingModel
,
apiKey
);
resolved
!=
nil
{
gid
:=
apiKey
.
Group
.
ID
cost
,
err
:=
s
.
billingService
.
CalculateCostUnified
(
CostInput
{
Ctx
:
ctx
,
Model
:
billingModel
,
GroupID
:
&
gid
,
RequestCount
:
1
,
SizeTier
:
result
.
ImageSize
,
RateMultiplier
:
multiplier
,
Resolver
:
s
.
resolver
,
Resolved
:
resolved
,
})
if
err
==
nil
{
return
cost
}
logger
.
LegacyPrintf
(
"service.openai_gateway"
,
"Calculate image channel cost failed: %v"
,
err
)
}
var
groupConfig
*
ImagePriceConfig
if
apiKey
!=
nil
&&
apiKey
.
Group
!=
nil
{
groupConfig
=
&
ImagePriceConfig
{
Price1K
:
apiKey
.
Group
.
ImagePrice1K
,
Price2K
:
apiKey
.
Group
.
ImagePrice2K
,
Price4K
:
apiKey
.
Group
.
ImagePrice4K
,
}
}
return
s
.
billingService
.
CalculateImageCost
(
billingModel
,
result
.
ImageSize
,
result
.
ImageCount
,
groupConfig
,
multiplier
)
}
func
(
s
*
OpenAIGatewayService
)
resolveOpenAIChannelPricing
(
ctx
context
.
Context
,
billingModel
string
,
apiKey
*
APIKey
)
*
ResolvedPricing
{
if
s
.
resolver
==
nil
||
apiKey
==
nil
||
apiKey
.
Group
==
nil
{
return
nil
}
gid
:=
apiKey
.
Group
.
ID
resolved
:=
s
.
resolver
.
Resolve
(
ctx
,
PricingInput
{
Model
:
billingModel
,
GroupID
:
&
gid
})
if
resolved
.
Source
==
PricingSourceChannel
{
return
resolved
}
return
nil
}
func
hasOpenAIImageUsageTokens
(
result
*
OpenAIForwardResult
)
bool
{
if
result
==
nil
{
return
false
}
return
result
.
Usage
.
InputTokens
>
0
||
result
.
Usage
.
OutputTokens
>
0
||
result
.
Usage
.
CacheCreationInputTokens
>
0
||
result
.
Usage
.
CacheReadInputTokens
>
0
||
result
.
Usage
.
ImageOutputTokens
>
0
}
// ParseCodexRateLimitHeaders extracts Codex usage limits from response headers.
// Exported for use in ratelimit_service when handling OpenAI 429 responses.
func
ParseCodexRateLimitHeaders
(
headers
http
.
Header
)
*
OpenAICodexUsageSnapshot
{
...
...
backend/internal/service/openai_images.go
0 → 100644
View file @
32107b4f
This diff is collapsed.
Click to expand it.
backend/internal/service/openai_images_test.go
0 → 100644
View file @
32107b4f
package
service
import
(
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
body
:=
[]
byte
(
`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","stream":true}`
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/images/generations"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
req
svc
:=
&
OpenAIGatewayService
{}
parsed
,
err
:=
svc
.
ParseOpenAIImagesRequest
(
c
,
body
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
parsed
)
require
.
Equal
(
t
,
"/v1/images/generations"
,
parsed
.
Endpoint
)
require
.
Equal
(
t
,
"gpt-image-2"
,
parsed
.
Model
)
require
.
Equal
(
t
,
"draw a cat"
,
parsed
.
Prompt
)
require
.
True
(
t
,
parsed
.
Stream
)
require
.
Equal
(
t
,
"1024x1024"
,
parsed
.
Size
)
require
.
Equal
(
t
,
"1K"
,
parsed
.
SizeTier
)
require
.
Equal
(
t
,
OpenAIImagesCapabilityNative
,
parsed
.
RequiredCapability
)
require
.
False
(
t
,
parsed
.
Multipart
)
}
func
TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
var
body
bytes
.
Buffer
writer
:=
multipart
.
NewWriter
(
&
body
)
require
.
NoError
(
t
,
writer
.
WriteField
(
"model"
,
"gpt-image-2"
))
require
.
NoError
(
t
,
writer
.
WriteField
(
"prompt"
,
"replace background"
))
require
.
NoError
(
t
,
writer
.
WriteField
(
"size"
,
"1536x1024"
))
part
,
err
:=
writer
.
CreateFormFile
(
"image"
,
"source.png"
)
require
.
NoError
(
t
,
err
)
_
,
err
=
part
.
Write
([]
byte
(
"fake-image-bytes"
))
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
writer
.
Close
())
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/images/edits"
,
bytes
.
NewReader
(
body
.
Bytes
()))
req
.
Header
.
Set
(
"Content-Type"
,
writer
.
FormDataContentType
())
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
req
svc
:=
&
OpenAIGatewayService
{}
parsed
,
err
:=
svc
.
ParseOpenAIImagesRequest
(
c
,
body
.
Bytes
())
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
parsed
)
require
.
Equal
(
t
,
"/v1/images/edits"
,
parsed
.
Endpoint
)
require
.
True
(
t
,
parsed
.
Multipart
)
require
.
Equal
(
t
,
"gpt-image-2"
,
parsed
.
Model
)
require
.
Equal
(
t
,
"replace background"
,
parsed
.
Prompt
)
require
.
Equal
(
t
,
"1536x1024"
,
parsed
.
Size
)
require
.
Equal
(
t
,
"2K"
,
parsed
.
SizeTier
)
require
.
Len
(
t
,
parsed
.
Uploads
,
1
)
require
.
Equal
(
t
,
OpenAIImagesCapabilityNative
,
parsed
.
RequiredCapability
)
}
func
TestOpenAIGatewayServiceParseOpenAIImagesRequest_PromptOnlyDefaultsRemainBasic
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
body
:=
[]
byte
(
`{"prompt":"draw a cat"}`
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/images/generations"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
req
svc
:=
&
OpenAIGatewayService
{}
parsed
,
err
:=
svc
.
ParseOpenAIImagesRequest
(
c
,
body
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
parsed
)
require
.
Equal
(
t
,
"gpt-image-2"
,
parsed
.
Model
)
require
.
Equal
(
t
,
OpenAIImagesCapabilityBasic
,
parsed
.
RequiredCapability
)
}
func
TestOpenAIGatewayServiceParseOpenAIImagesRequest_ExplicitSizeRequiresNativeCapability
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
body
:=
[]
byte
(
`{"prompt":"draw a cat","size":"1024x1024"}`
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/images/generations"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
req
svc
:=
&
OpenAIGatewayService
{}
parsed
,
err
:=
svc
.
ParseOpenAIImagesRequest
(
c
,
body
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
parsed
)
require
.
Equal
(
t
,
OpenAIImagesCapabilityNative
,
parsed
.
RequiredCapability
)
}
backend/internal/service/ops_retry.go
View file @
32107b4f
...
...
@@ -388,7 +388,7 @@ func (s *OpsService) executeRetry(ctx context.Context, errorLog *OpsErrorLogDeta
func
detectOpsRetryType
(
path
string
)
opsRetryRequestType
{
p
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
path
))
switch
{
case
strings
.
Contains
(
p
,
"/responses"
)
:
case
strings
.
Contains
(
p
,
"/responses"
)
,
strings
.
Contains
(
p
,
"/images/"
)
:
return
opsRetryTypeOpenAI
case
strings
.
Contains
(
p
,
"/v1beta/"
)
:
return
opsRetryTypeGeminiV1B
...
...
backend/internal/web/embed_on.go
View file @
32107b4f
...
...
@@ -305,7 +305,8 @@ func shouldBypassEmbeddedFrontend(path string) bool {
strings
.
HasPrefix
(
trimmed
,
"/setup/"
)
||
trimmed
==
"/health"
||
trimmed
==
"/responses"
||
strings
.
HasPrefix
(
trimmed
,
"/responses/"
)
strings
.
HasPrefix
(
trimmed
,
"/responses/"
)
||
strings
.
HasPrefix
(
trimmed
,
"/images/"
)
}
func
serveIndexHTML
(
c
*
gin
.
Context
,
fsys
fs
.
FS
)
{
...
...
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