sync: bring over remaining release/custom-0.1.115 changes

- Extract PublicSettingsInjectionPayload named struct with drift test
- Add channel_monitor_default_interval_seconds to SSR injection
- Add image_output_price to SupportedModelChip
- Simplify AppSidebar buildSelfNavItems (admins see available channels)
- Add gateway WARN logs for 503 no-available-accounts branches
- Wire ChannelMonitorRunner into provideCleanup for graceful shutdown
- Add migrations 130/131 (CC template userid fix + mimicry field cleanup)
- Clean up fork-only features (sora, claude max simulation, client affinity)
- Remove ~320 obsolete i18n keys
- Add codexUsage utility, WechatServiceButton, BulkEditAccountModal
- Tidy go.sum
This commit is contained in:
erio
2026-04-23 20:55:18 +08:00
parent d5dac84e12
commit 748a84d871
76 changed files with 1380 additions and 1699 deletions

View File

@@ -45,11 +45,8 @@ const (
openAIChatGPTConversationPrepareURL = "https://chatgpt.com/backend-api/f/conversation/prepare"
openAIChatGPTChatRequirementsURL = "https://chatgpt.com/backend-api/sentinel/chat-requirements"
openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
openAIImageRequirementsDiff = "0fffff"
openAIImageLifecycleTimeout = 2 * time.Minute
openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download
openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part
openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
openAIImageRequirementsDiff = "0fffff"
)
type OpenAIImagesCapability string
@@ -151,9 +148,6 @@ func (s *OpenAIGatewayService) ParseOpenAIImagesRequest(c *gin.Context, body []b
}
applyOpenAIImagesDefaults(req)
if err := validateOpenAIImagesModel(req.Model); err != nil {
return nil, err
}
req.SizeTier = normalizeOpenAIImageSizeTier(req.Size)
req.RequiredCapability = classifyOpenAIImagesCapability(req)
return req, nil
@@ -220,7 +214,7 @@ func parseOpenAIImagesMultipartRequest(body []byte, contentType string, req *Ope
continue
}
data, err := io.ReadAll(io.LimitReader(part, openAIImageMaxUploadPartSize))
data, err := io.ReadAll(part)
_ = part.Close()
if err != nil {
return fmt.Errorf("read multipart field %s: %w", name, err)
@@ -301,21 +295,6 @@ func applyOpenAIImagesDefaults(req *OpenAIImagesRequest) {
req.Model = "gpt-image-2"
}
func isOpenAIImageGenerationModel(model string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-image-")
}
func validateOpenAIImagesModel(model string) error {
model = strings.TrimSpace(model)
if isOpenAIImageGenerationModel(model) {
return nil
}
if model == "" {
return fmt.Errorf("images endpoint requires an image model")
}
return fmt.Errorf("images endpoint requires an image model, got %q", model)
}
func normalizeOpenAIImagesEndpointPath(path string) string {
trimmed := strings.TrimSpace(path)
switch {
@@ -421,21 +400,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
if mapped := strings.TrimSpace(channelMappedModel); mapped != "" {
requestModel = mapped
}
if err := validateOpenAIImagesModel(requestModel); err != nil {
return nil, err
}
upstreamModel := account.GetMappedModel(requestModel)
if err := validateOpenAIImagesModel(upstreamModel); err != nil {
return nil, err
}
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Images request routing request_model=%s upstream_model=%s endpoint=%s account_type=%s",
strings.TrimSpace(parsed.Model),
upstreamModel,
parsed.Endpoint,
account.Type,
)
forwardBody, forwardContentType, err := rewriteOpenAIImagesModel(body, parsed.ContentType, upstreamModel)
if err != nil {
return nil, err
@@ -794,17 +759,6 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
if mapped := strings.TrimSpace(channelMappedModel); mapped != "" {
requestModel = mapped
}
if err := validateOpenAIImagesModel(requestModel); err != nil {
return nil, err
}
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Images request routing request_model=%s endpoint=%s account_type=%s uploads=%d",
requestModel,
parsed.Endpoint,
account.Type,
len(parsed.Uploads),
)
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
@@ -890,18 +844,8 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
return nil, err
}
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, nil)
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Image extraction stream conversation_id=%s total_assets=%d file_service_assets=%d direct_assets=%d",
conversationID,
len(pointerInfos),
countOpenAIFileServicePointerInfos(pointerInfos),
countOpenAIDirectImageAssets(pointerInfos),
)
lifecycleCtx, releaseLifecycleCtx := detachOpenAIImageLifecycleContext(ctx, openAIImageLifecycleTimeout)
defer releaseLifecycleCtx()
if conversationID != "" && !hasOpenAIFileServicePointerInfos(pointerInfos) {
polledPointers, pollErr := pollOpenAIImageConversation(lifecycleCtx, client, headers, conversationID)
polledPointers, pollErr := pollOpenAIImageConversation(ctx, client, headers, conversationID)
if pollErr != nil {
return nil, s.wrapOpenAIImageBackendError(ctx, c, account, pollErr)
}
@@ -909,11 +853,10 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
}
pointerInfos = preferOpenAIFileServicePointerInfos(pointerInfos)
if len(pointerInfos) == 0 {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Image extraction yielded no assets conversation_id=%s", conversationID)
return nil, fmt.Errorf("openai image conversation returned no downloadable images")
}
responseBody, imageCount, err := buildOpenAIImageResponse(lifecycleCtx, client, headers, conversationID, pointerInfos)
responseBody, imageCount, err := buildOpenAIImageResponse(ctx, client, headers, conversationID, pointerInfos)
if err != nil {
return nil, s.wrapOpenAIImageBackendError(ctx, c, account, err)
}
@@ -1340,11 +1283,8 @@ func buildOpenAIImageConversationRequest(parsed *OpenAIImagesRequest, parentMess
}
type openAIImagePointerInfo struct {
Pointer string
DownloadURL string
B64JSON string
MimeType string
Prompt string
Pointer string
Prompt string
}
type openAIImageToolMessage struct {
@@ -1396,6 +1336,10 @@ func collectOpenAIImagePointers(body []byte) []openAIImagePointerInfo {
if len(body) == 0 {
return nil
}
matches := openAIImagePointerMatches(body)
if len(matches) == 0 {
return nil
}
prompt := ""
for _, path := range []string{
"message.metadata.dalle.prompt",
@@ -1407,12 +1351,11 @@ func collectOpenAIImagePointers(body []byte) []openAIImagePointerInfo {
break
}
}
matches := openAIImagePointerMatches(body)
out := make([]openAIImagePointerInfo, 0, len(matches))
for _, pointer := range matches {
out = append(out, openAIImagePointerInfo{Pointer: pointer, Prompt: prompt})
}
return mergeOpenAIImagePointerInfos(out, collectOpenAIImageInlineAssets(body, prompt))
return out
}
func openAIImagePointerMatches(body []byte) []string {
@@ -1451,72 +1394,27 @@ func mergeOpenAIImagePointerInfos(existing []openAIImagePointerInfo, next []open
seen := make(map[string]openAIImagePointerInfo, len(existing)+len(next))
out := make([]openAIImagePointerInfo, 0, len(existing)+len(next))
for _, item := range existing {
if key := item.identityKey(); key != "" {
seen[key] = item
}
seen[item.Pointer] = item
out = append(out, item)
}
for _, item := range next {
key := item.identityKey()
if key == "" {
continue
}
if existingItem, ok := seen[key]; ok {
merged := mergeOpenAIImagePointerInfo(existingItem, item)
if merged != existingItem {
if existingItem, ok := seen[item.Pointer]; ok {
if existingItem.Prompt == "" && item.Prompt != "" {
for i := range out {
if out[i].identityKey() == key {
out[i] = merged
if out[i].Pointer == item.Pointer {
out[i].Prompt = item.Prompt
break
}
}
seen[key] = merged
}
continue
}
seen[key] = item
seen[item.Pointer] = item
out = append(out, item)
}
return out
}
func (i openAIImagePointerInfo) identityKey() string {
switch {
case strings.TrimSpace(i.Pointer) != "":
return "pointer:" + strings.TrimSpace(i.Pointer)
case strings.TrimSpace(i.DownloadURL) != "":
return "download:" + strings.TrimSpace(i.DownloadURL)
case strings.TrimSpace(i.B64JSON) != "":
b64 := strings.TrimSpace(i.B64JSON)
if len(b64) > 64 {
b64 = b64[:64]
}
return "b64:" + b64
default:
return ""
}
}
func mergeOpenAIImagePointerInfo(existing, next openAIImagePointerInfo) openAIImagePointerInfo {
merged := existing
if strings.TrimSpace(merged.Pointer) == "" {
merged.Pointer = next.Pointer
}
if strings.TrimSpace(merged.DownloadURL) == "" {
merged.DownloadURL = next.DownloadURL
}
if strings.TrimSpace(merged.B64JSON) == "" {
merged.B64JSON = next.B64JSON
}
if strings.TrimSpace(merged.MimeType) == "" {
merged.MimeType = next.MimeType
}
if strings.TrimSpace(merged.Prompt) == "" {
merged.Prompt = next.Prompt
}
return merged
}
func hasOpenAIFileServicePointerInfos(items []openAIImagePointerInfo) bool {
for _, item := range items {
if strings.HasPrefix(item.Pointer, "file-service://") {
@@ -1526,26 +1424,6 @@ func hasOpenAIFileServicePointerInfos(items []openAIImagePointerInfo) bool {
return false
}
func countOpenAIFileServicePointerInfos(items []openAIImagePointerInfo) int {
count := 0
for _, item := range items {
if strings.HasPrefix(item.Pointer, "file-service://") {
count++
}
}
return count
}
func countOpenAIDirectImageAssets(items []openAIImagePointerInfo) int {
count := 0
for _, item := range items {
if strings.TrimSpace(item.DownloadURL) != "" || strings.TrimSpace(item.B64JSON) != "" {
count++
}
}
return count
}
func preferOpenAIFileServicePointerInfos(items []openAIImagePointerInfo) []openAIImagePointerInfo {
if !hasOpenAIFileServicePointerInfos(items) {
return items
@@ -1713,7 +1591,11 @@ func buildOpenAIImageResponse(
}
items := make([]responseItem, 0, len(pointers))
for _, pointer := range pointers {
data, err := resolveOpenAIImageBytes(ctx, client, headers, conversationID, pointer)
downloadURL, err := fetchOpenAIImageDownloadURL(ctx, client, headers, conversationID, pointer.Pointer)
if err != nil {
return nil, 0, err
}
data, err := downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
if err != nil {
return nil, 0, err
}
@@ -1733,136 +1615,6 @@ func buildOpenAIImageResponse(
return body, len(items), nil
}
func resolveOpenAIImageBytes(
ctx context.Context,
client *req.Client,
headers http.Header,
conversationID string,
pointer openAIImagePointerInfo,
) ([]byte, error) {
if normalized := normalizeOpenAIImageBase64(pointer.B64JSON); normalized != "" {
return base64.StdEncoding.DecodeString(normalized)
}
if downloadURL := strings.TrimSpace(pointer.DownloadURL); downloadURL != "" {
return downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
}
if strings.TrimSpace(pointer.Pointer) == "" {
return nil, fmt.Errorf("image asset is missing pointer, url, and base64 data")
}
downloadURL, err := fetchOpenAIImageDownloadURL(ctx, client, headers, conversationID, pointer.Pointer)
if err != nil {
return nil, err
}
return downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
}
func normalizeOpenAIImageBase64(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(raw), "data:") {
if idx := strings.Index(raw, ","); idx >= 0 && idx+1 < len(raw) {
raw = raw[idx+1:]
}
}
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, "=") + strings.Repeat("=", (4-len(raw)%4)%4)
if raw == "" {
return ""
}
if _, err := base64.StdEncoding.DecodeString(raw); err != nil {
return ""
}
return raw
}
func collectOpenAIImageInlineAssets(body []byte, fallbackPrompt string) []openAIImagePointerInfo {
if len(body) == 0 || !gjson.ValidBytes(body) {
return nil
}
var decoded any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil
}
var out []openAIImagePointerInfo
walkOpenAIImageInlineAssets(decoded, strings.TrimSpace(fallbackPrompt), &out)
return out
}
func walkOpenAIImageInlineAssets(node any, prompt string, out *[]openAIImagePointerInfo) {
switch value := node.(type) {
case map[string]any:
localPrompt := prompt
for _, key := range []string{"revised_prompt", "image_gen_title", "prompt"} {
if v, ok := value[key].(string); ok && strings.TrimSpace(v) != "" {
localPrompt = strings.TrimSpace(v)
break
}
}
item := openAIImagePointerInfo{
Prompt: localPrompt,
Pointer: firstNonEmptyString(value["asset_pointer"], value["pointer"]),
DownloadURL: firstNonEmptyString(value["download_url"], value["url"], value["image_url"]),
B64JSON: firstNonEmptyString(value["b64_json"], value["base64"], value["image_base64"]),
MimeType: firstNonEmptyString(value["mime_type"], value["mimeType"], value["content_type"]),
}
switch {
case strings.HasPrefix(strings.TrimSpace(item.Pointer), "file-service://"),
strings.HasPrefix(strings.TrimSpace(item.Pointer), "sediment://"),
isLikelyOpenAIImageDownloadURL(item.DownloadURL),
normalizeOpenAIImageBase64(item.B64JSON) != "":
*out = append(*out, item)
}
for _, child := range value {
walkOpenAIImageInlineAssets(child, localPrompt, out)
}
case []any:
for _, child := range value {
walkOpenAIImageInlineAssets(child, prompt, out)
}
}
}
func firstNonEmptyString(values ...any) string {
for _, value := range values {
if s, ok := value.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
func isLikelyOpenAIImageDownloadURL(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
if strings.HasPrefix(strings.ToLower(raw), "data:image/") {
return true
}
if !strings.HasPrefix(strings.ToLower(raw), "http://") && !strings.HasPrefix(strings.ToLower(raw), "https://") {
return false
}
lower := strings.ToLower(raw)
return strings.Contains(lower, "/download") ||
strings.Contains(lower, ".png") ||
strings.Contains(lower, ".jpg") ||
strings.Contains(lower, ".jpeg") ||
strings.Contains(lower, ".webp")
}
func detachOpenAIImageLifecycleContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
base := context.Background()
if ctx != nil {
base = context.WithoutCancel(ctx)
}
if timeout <= 0 {
return base, func() {}
}
return context.WithTimeout(base, timeout)
}
func fetchOpenAIImageDownloadURL(
ctx context.Context,
client *req.Client,
@@ -1954,7 +1706,7 @@ func downloadOpenAIImageBytes(ctx context.Context, client *req.Client, headers h
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, newOpenAIImageStatusError(resp, "download image bytes failed")
}
return io.ReadAll(io.LimitReader(resp.Body, openAIImageMaxDownloadBytes))
return io.ReadAll(resp.Body)
}
func handleOpenAIImageBackendError(resp *req.Response) error {