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()) }