Files
sub2api/backend/internal/service/image_generation_intent.go
T

524 lines
16 KiB
Go
Raw Normal View History

package service
import (
"strings"
"github.com/tidwall/gjson"
)
const (
openAIResponsesEndpoint = "/v1/responses"
openAIResponsesCompactEndpoint = "/v1/responses/compact"
responsesLiteHeader = "X-OpenAI-Internal-Codex-Responses-Lite"
responsesLiteHeaderKey = "x-openai-internal-codex-responses-lite"
responsesLiteWSMetadataKey = "ws_request_header_x_openai_internal_codex_responses_lite"
imageGenerationPermissionMessage = "Image generation is not enabled for this group"
)
func isOpenAIResponsesLiteHeader(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "true")
}
func isOpenAIResponsesLiteWebSocketPayload(body []byte) bool {
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
return isOpenAIResponsesLiteHeader(gjson.GetBytes(body, "client_metadata."+responsesLiteWSMetadataKey).String())
}
// ImageGenerationPermissionMessage returns the stable end-user error text for disabled groups.
func ImageGenerationPermissionMessage() string {
return imageGenerationPermissionMessage
}
// GroupAllowsImageGeneration preserves ungrouped-key behavior and enforces the flag when a group is present.
func GroupAllowsImageGeneration(group *Group) bool {
return group == nil || group.AllowImageGeneration
}
// IsImageGenerationIntent classifies requests that can produce generated images.
func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool {
if IsImageGenerationEndpoint(endpoint) {
return true
}
if isOpenAIImageGenerationModel(requestedModel) {
return true
}
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
var modelSeen, toolsSeen, inputSeen, toolChoiceSeen bool
imageIntent := false
parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
// GetBytes returns the first duplicate key; retain that behavior while walking the root once.
switch key.Str {
case "model":
if !modelSeen {
modelSeen = true
imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String()))
}
case "tools":
if !toolsSeen {
toolsSeen = true
imageIntent = openAIJSONToolsContainImageGeneration(value)
}
case "input":
if !inputSeen {
inputSeen = true
imageIntent = openAIJSONInputContainsImageGenTool(value)
}
case "tool_choice":
if !toolChoiceSeen {
toolChoiceSeen = true
imageIntent = openAIJSONToolChoiceSelectsImageGeneration(value)
}
}
return !imageIntent && (!modelSeen || !toolsSeen || !inputSeen || !toolChoiceSeen)
})
return imageIntent
}
// IsExplicitImageGenerationIntent 仅检测原生 image_generation 工具、图片模型和显式 tool_choice
// 不检测被动的 image_gen namespace 声明。用于 capability 路由决策——被动 namespace 不应
// 强制要求原生 Responses 能力,否则 Chat Completions-only 账号会被误过滤(#4476)。
func IsExplicitImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool {
if IsImageGenerationEndpoint(endpoint) || isOpenAIImageGenerationModel(requestedModel) {
return true
}
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
var modelSeen, toolsSeen, toolChoiceSeen bool
imageIntent := false
parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
switch key.Str {
case "model":
if !modelSeen {
modelSeen = true
imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String()))
}
case "tools":
if !toolsSeen {
toolsSeen = true
imageIntent = openAIJSONToolsContainNativeImageGeneration(value)
}
case "tool_choice":
if !toolChoiceSeen {
toolChoiceSeen = true
imageIntent = openAIJSONToolChoiceSelectsExplicitImageGeneration(value)
}
}
return !imageIntent && (!modelSeen || !toolsSeen || !toolChoiceSeen)
})
return imageIntent
}
// IsImageGenerationIntentForPlatform applies platform-specific intent rules.
//
// Codex advertises the image_gen namespace on ordinary Responses requests so
// that it is available if the model needs it. Grok strips namespace and
// Responses Lite additional_tools declarations before forwarding, so those
// declarations alone must not turn every Codex request into an image request.
// Native image_generation tools, explicit image selection and image models
// remain image intent. Other platforms retain the original declaration rule.
func IsImageGenerationIntentForPlatform(endpoint string, requestedModel string, body []byte, platform string) bool {
if !strings.EqualFold(strings.TrimSpace(platform), PlatformGrok) {
return IsImageGenerationIntent(endpoint, requestedModel, body)
}
return isExplicitGrokImageGenerationIntent(endpoint, requestedModel, body)
}
func isExplicitGrokImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool {
if IsImageGenerationEndpoint(endpoint) || isOpenAIImageGenerationModel(requestedModel) {
return true
}
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
var modelSeen, toolsSeen, toolChoiceSeen bool
imageIntent := false
parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
switch key.Str {
case "model":
if !modelSeen {
modelSeen = true
imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String()))
}
case "tools":
if !toolsSeen {
toolsSeen = true
// Grok removes namespace catalogs before forwarding. Native
// image_generation remains an explicit capability request.
imageIntent = openAIJSONToolsContainNativeImageGeneration(value)
}
case "tool_choice":
if !toolChoiceSeen {
toolChoiceSeen = true
imageIntent = openAIJSONToolChoiceSelectsExplicitImageGeneration(value)
}
}
return !imageIntent && (!modelSeen || !toolsSeen || !toolChoiceSeen)
})
return imageIntent
}
// IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation.
func IsImageGenerationIntentMap(endpoint string, requestedModel string, reqBody map[string]any) bool {
if IsImageGenerationEndpoint(endpoint) {
return true
}
if isOpenAIImageGenerationModel(requestedModel) {
return true
}
if reqBody == nil {
return false
}
if isOpenAIImageGenerationModel(firstNonEmptyString(reqBody["model"])) {
return true
}
if hasOpenAIImageGenerationTool(reqBody) {
return true
}
return openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"])
}
// IsImageGenerationEndpoint identifies dedicated generated-image endpoints.
func IsImageGenerationEndpoint(endpoint string) bool {
switch normalizeImageGenerationEndpoint(endpoint) {
case "/v1/images/generations", "/v1/images/edits", "/images/generations", "/images/edits":
return true
default:
return false
}
}
func normalizeImageGenerationEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(strings.ToLower(endpoint))
if endpoint == "" {
return ""
}
endpoint = strings.TrimPrefix(endpoint, "https://api.openai.com")
if idx := strings.IndexByte(endpoint, '?'); idx >= 0 {
endpoint = endpoint[:idx]
}
return strings.TrimRight(endpoint, "/")
}
func openAIJSONToolsContainImageGeneration(tools gjson.Result) bool {
if !tools.IsArray() {
return false
}
found := false
tools.ForEach(func(_, item gjson.Result) bool {
if isOpenAIImageGenerationType(openAIJSONString(item.Get("type"))) {
found = true
return false
}
if isImageGenNamespaceTool(item) {
found = true
return false
}
return true
})
return found
}
func openAIJSONToolsContainNativeImageGeneration(tools gjson.Result) bool {
if !tools.IsArray() {
return false
}
found := false
tools.ForEach(func(_, item gjson.Result) bool {
found = isOpenAIImageGenerationType(openAIJSONString(item.Get("type")))
return !found
})
return found
}
func isOpenAIImageGenerationType(value string) bool {
return strings.TrimSpace(value) == "image_generation"
}
func isOpenAIImageGenNamespaceName(value string) bool {
return strings.TrimSpace(value) == "image_gen"
}
// isImageGenNamespaceTool detects the namespace advertised by Codex's built-in
// image-generation extension instead of a hosted image_generation tool.
func isImageGenNamespaceTool(tool gjson.Result) bool {
return openAIJSONString(tool.Get("type")) == "namespace" &&
isOpenAIImageGenNamespaceName(openAIJSONString(tool.Get("name")))
}
// openAIJSONInputContainsImageGenTool scans Responses input items for
// additional_tools entries that declare the image_gen namespace. This covers
// the "Responses Lite" format where tools are embedded inside input items
// rather than top-level tools.
func openAIJSONInputContainsImageGenTool(input gjson.Result) bool {
if !input.IsArray() {
return false
}
found := false
input.ForEach(func(_, item gjson.Result) bool {
if openAIJSONString(item.Get("type")) != "additional_tools" {
return true
}
found = openAIJSONToolsContainImageGeneration(item.Get("tools"))
return !found
})
return found
}
func openAIRequestBodyHasImageGenerationDeclaration(body []byte) bool {
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
return openAIJSONToolsContainImageGeneration(gjson.GetBytes(body, "tools")) ||
openAIJSONInputContainsImageGenTool(gjson.GetBytes(body, "input")) ||
openAIJSONToolChoiceSelectsImageGeneration(gjson.GetBytes(body, "tool_choice"))
}
func openAIRequestBodyImageGenerationToolNeedsNormalization(body []byte) bool {
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return false
}
needsNormalization := false
tools.ForEach(func(_, item gjson.Result) bool {
if openAIJSONString(item.Get("type")) != "image_generation" {
return true
}
// 只有旧字段需要迁移时才进入 map 修改,纯计费读取保持 raw 路径。
if item.Get("format").Exists() || item.Get("compression").Exists() {
needsNormalization = true
return false
}
return true
})
return needsNormalization
}
func openAIJSONToolChoiceSelectsImageGeneration(choice gjson.Result) bool {
if !choice.Exists() {
return false
}
if choice.Type == gjson.String {
return isOpenAIImageGenerationType(choice.String())
}
if !choice.IsObject() {
return false
}
choiceType := openAIJSONString(choice.Get("type"))
if isOpenAIImageGenerationType(choiceType) {
return true
}
if choiceType == "namespace" &&
(isOpenAIImageGenNamespaceName(openAIJSONString(choice.Get("name"))) ||
isOpenAIImageGenNamespaceName(openAIJSONString(choice.Get("namespace")))) {
return true
}
if tool := choice.Get("tool"); tool.IsObject() && openAIJSONToolChoiceSelectsImageGeneration(tool) {
return true
}
if isOpenAIImageGenerationType(openAIJSONString(choice.Get("function.name"))) {
return true
}
return false
}
func openAIJSONToolChoiceSelectsExplicitImageGeneration(choice gjson.Result) bool {
if openAIJSONToolChoiceSelectsImageGeneration(choice) {
return true
}
if !choice.IsObject() {
return false
}
if tool := choice.Get("tool"); tool.IsObject() && openAIJSONToolChoiceSelectsExplicitImageGeneration(tool) {
return true
}
if isOpenAIImageGenFunctionReference(
openAIJSONString(choice.Get("namespace")),
openAIJSONString(choice.Get("name")),
) {
return true
}
if fn := choice.Get("function"); fn.IsObject() {
return isOpenAIImageGenFunctionReference(
openAIJSONString(fn.Get("namespace")),
openAIJSONString(fn.Get("name")),
)
}
return false
}
func isOpenAIImageGenFunctionReference(namespace string, name string) bool {
namespace = strings.TrimSpace(namespace)
name = strings.TrimSpace(name)
if namespace == "image_gen" && name == "imagegen" {
return true
}
switch name {
case "image_gen.imagegen", "image_gen__imagegen":
return true
default:
return false
}
}
func openAIAnyToolChoiceSelectsImageGeneration(choice any) bool {
switch v := choice.(type) {
case string:
return isOpenAIImageGenerationType(v)
case map[string]any:
choiceType := strings.TrimSpace(firstNonEmptyString(v["type"]))
if isOpenAIImageGenerationType(choiceType) {
return true
}
if choiceType == "namespace" &&
(isOpenAIImageGenNamespaceName(firstNonEmptyString(v["name"])) ||
isOpenAIImageGenNamespaceName(firstNonEmptyString(v["namespace"]))) {
return true
}
if tool, ok := v["tool"].(map[string]any); ok && openAIAnyToolChoiceSelectsImageGeneration(tool) {
return true
}
if fn, ok := v["function"].(map[string]any); ok && isOpenAIImageGenerationType(firstNonEmptyString(fn["name"])) {
return true
}
}
return false
}
func getAPIKeyFromContext(c interface{ Get(string) (any, bool) }) *APIKey {
if c == nil {
return nil
}
v, exists := c.Get("api_key")
if !exists {
return nil
}
apiKey, _ := v.(*APIKey)
return apiKey
}
func apiKeyGroup(apiKey *APIKey) *Group {
if apiKey == nil {
return nil
}
return apiKey.Group
}
type OpenAIResponsesImageBillingConfig struct {
Model string
SizeTier string
InputSize string
}
func resolveOpenAIResponsesImageBillingConfigDetailed(reqBody map[string]any, fallbackModel string) (OpenAIResponsesImageBillingConfig, error) {
imageModel := ""
imageSize := ""
hasImageTool := false
if reqBody != nil {
rawTools, _ := reqBody["tools"].([]any)
for _, rawTool := range rawTools {
toolMap, ok := rawTool.(map[string]any)
if !ok || strings.TrimSpace(firstNonEmptyString(toolMap["type"])) != "image_generation" {
continue
}
hasImageTool = true
imageModel = strings.TrimSpace(firstNonEmptyString(toolMap["model"]))
imageSize = strings.TrimSpace(firstNonEmptyString(toolMap["size"]))
break
}
if imageSize == "" {
imageSize = strings.TrimSpace(firstNonEmptyString(reqBody["size"]))
}
}
if imageModel == "" && reqBody != nil {
bodyModel := strings.TrimSpace(firstNonEmptyString(reqBody["model"]))
if isOpenAIImageBillingModelAlias(bodyModel) || !hasImageTool {
imageModel = bodyModel
}
}
if imageModel == "" && hasImageTool {
imageModel = "gpt-image-2"
}
if imageModel == "" {
imageModel = strings.TrimSpace(fallbackModel)
}
sizeTier := normalizeOpenAIImageSizeTier(imageSize)
return OpenAIResponsesImageBillingConfig{
Model: imageModel,
SizeTier: sizeTier,
InputSize: imageSize,
}, nil
}
func resolveOpenAIResponsesImageBillingConfigFromBody(body []byte, fallbackModel string) (string, string, error) {
cfg, err := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, fallbackModel)
if err != nil {
return "", "", err
}
return cfg.Model, cfg.SizeTier, nil
}
func resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body []byte, fallbackModel string) (OpenAIResponsesImageBillingConfig, error) {
imageModel := ""
imageSize := ""
hasImageTool := false
if len(body) > 0 && gjson.ValidBytes(body) {
tools := gjson.GetBytes(body, "tools")
if tools.IsArray() {
tools.ForEach(func(_, item gjson.Result) bool {
if openAIJSONString(item.Get("type")) != "image_generation" {
return true
}
hasImageTool = true
imageModel = openAIJSONString(item.Get("model"))
imageSize = openAIJSONString(item.Get("size"))
return false
})
}
if imageSize == "" {
imageSize = openAIJSONString(gjson.GetBytes(body, "size"))
}
if imageModel == "" {
bodyModel := openAIJSONString(gjson.GetBytes(body, "model"))
if isOpenAIImageBillingModelAlias(bodyModel) || !hasImageTool {
imageModel = bodyModel
}
}
}
if imageModel == "" && hasImageTool {
imageModel = "gpt-image-2"
}
if imageModel == "" {
imageModel = strings.TrimSpace(fallbackModel)
}
return OpenAIResponsesImageBillingConfig{
Model: imageModel,
SizeTier: normalizeOpenAIImageSizeTier(imageSize),
InputSize: imageSize,
}, nil
}
func isOpenAIImageBillingModelAlias(model string) bool {
normalized := strings.ToLower(strings.TrimSpace(model))
if normalized == "" {
return false
}
return isOpenAIImageGenerationModel(normalized) || strings.Contains(normalized, "image")
}
func openAIJSONString(value gjson.Result) string {
if value.Type != gjson.String {
return ""
}
return strings.TrimSpace(value.String())
}