feat(*): 图片模型渠道 - 新增 Grok JSON 生图与单图编辑协议

This commit is contained in:
ddCat committed 2026-08-07 14:58:26 +08:00
1 parent 84f13fffa8
commit abec135d26
15 files changed
+207 -10

No files matched your search

+2 -2
View File
@@ -82,7 +82,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
}
if apiFormat == "openai" {
multipartAllowed := mediaType == "multipart/form-data" && (strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/videos"))
jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks"))
jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks"))
if len(query) != 0 || (!multipartAllowed && !jsonAllowed) {
return errors.New("自定义渠道不允许访问该上游接口")
}
@@ -183,7 +183,7 @@ func interfaceAllowsProxyPath(interfaceType model.ChannelInterfaceType, requestP
return requestPath == "/chat/completions"
case model.ChannelInterfaceOpenAIResponse:
return requestPath == "/responses"
case model.ChannelInterfaceOpenAIImage:
case model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage:
return requestPath == "/images/generations" || requestPath == "/images/edits"
case model.ChannelInterfaceVolcengineArkImage:
return requestPath == "/images/generations"
@@ -18,6 +18,14 @@ func TestAuthorizeSystemProxyAllowsConfiguredGenerationModel(t *testing.T) {
}
}
func TestAuthorizeSystemProxyAllowsGrokImageJSONEdits(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["grok-imagine-image"]`}
body := []byte(`{"model":"grok-imagine-image","prompt":"edit"}`)
if err := authorizeSystemProxy(channel, model.ChannelInterfaceGrokImage, http.MethodPost, "/images/edits", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
}
func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
tests := []struct {
method string
@@ -30,6 +38,7 @@ func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
{method: http.MethodPost, target: "https://api.example.com/v1/chat/completions", apiFormat: "openai", contentType: "application/json; charset=utf-8"},
{method: http.MethodPost, target: "https://api.example.com/v1/audio/speech", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/images/edits", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
{method: http.MethodPost, target: "https://api.example.com/v1/images/edits", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/videos", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/videos", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
{method: http.MethodGet, target: "https://api.example.com/v1/videos/task-1/content", apiFormat: "openai"},
+1
View File
@@ -61,6 +61,7 @@ const (
ChannelInterfaceChatCompletion ChannelInterfaceType = "chat-completion"
ChannelInterfaceOpenAIResponse ChannelInterfaceType = "openai-response"
ChannelInterfaceOpenAIImage ChannelInterfaceType = "openai-image"
ChannelInterfaceGrokImage ChannelInterfaceType = "grok-image"
ChannelInterfaceVolcengineArkImage ChannelInterfaceType = "volcengine-ark-image"
ChannelInterfaceVolcengineJiMengImage ChannelInterfaceType = "volcengine-jimeng-image"
ChannelInterfaceOpenAIAudio ChannelInterfaceType = "openai-audio"
+1 -1
View File
@@ -734,7 +734,7 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
func validChannelInterfaceType(value model.ChannelInterfaceType) bool {
switch value {
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
return true
default:
return false
+1 -1
View File
@@ -454,7 +454,7 @@ func (s *Service) syncChannelModelNames(channel *model.ModelChannel) error {
func capabilityForProtocol(protocol model.ChannelInterfaceType) string {
switch protocol {
case model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage:
case model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage:
return "image"
case model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio:
return "audio"
+10 -1
View File
@@ -106,6 +106,15 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab
MaxOutputs: 15,
}
switch model.ChannelInterfaceType(protocol) {
case model.ChannelInterfaceGrokImage:
image.References.MaxImages = 1
image.References.MaskSupported = false
image.Size = ImageSizeConfig{Parameter: "none", Values: []string{}, Default: "auto", AllowCustom: false}
image.Quality = ImageQualityConfig{Supported: false, Values: []string{}, Default: "auto"}
image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false}
image.ResponseFormat = ParameterSupport{Supported: true}
image.OutputFormat = ParameterSupport{Supported: false}
image.MaxOutputs = 1
case model.ChannelInterfaceVolcengineArkImage:
image.References.MaskSupported = false
image.Quality.Supported = false
@@ -120,7 +129,7 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab
image.ResponseFormat.Supported = false
image.OutputFormat.Supported = false
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") {
if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") {
image.References.MaxImages = 0
image.References.MaskSupported = false
image.Size = ImageSizeConfig{Parameter: "none", Values: []string{}, Default: "auto", AllowCustom: false}
+46 -1
View File
@@ -635,6 +635,9 @@ func systemChannelIDFromBaseURL(baseURL string) string {
}
func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) {
return runGrokImageTask(ctx, input)
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengImage) {
return runVolcengineJiMengImageTask(ctx, input)
}
@@ -720,6 +723,48 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]
return map[string]interface{}{"mode": "image", "images": images}, nil
}
func runGrokImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
body, path, err := grokImageRequestBody(input)
if err != nil {
return nil, err
}
var payload imageResponse
if err := postJSON(ctx, input.Config, path, body, &payload); err != nil {
return nil, err
}
images, err := imageDataURLs(payload)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "image", "images": images}, nil
}
func grokImageRequestBody(input canvasGenerationInput) (grokImageRequest, string, error) {
if input.Mask != nil {
return grokImageRequest{}, "", errors.New("Grok 图片协议不支持蒙版编辑,请移除蒙版后重试")
}
body := grokImageRequest{Model: input.Config.Model, Prompt: withSystemPrompt(input.Config, input.Prompt), N: 1, ResponseFormat: "url"}
if len(input.ReferenceImages) == 0 {
return body, "/images/generations", nil
}
if len(input.ReferenceImages) != 1 {
return grokImageRequest{}, "", fmt.Errorf("Grok 图片编辑只支持 1 张参考图,当前连接了 %d 张", len(input.ReferenceImages))
}
imageURL, err := grokImageInputURL(input.ReferenceImages[0])
if err != nil {
return grokImageRequest{}, "", err
}
body.Image = &grokImageInput{URL: imageURL}
return body, "/images/edits", nil
}
func grokImageInputURL(media providerMedia) (string, error) {
if isPublicMediaURL(strings.TrimSpace(media.URL)) {
return strings.TrimSpace(media.URL), nil
}
return openAIImageInputURL(media)
}
const volcengineArkImageMaxPixels = 4624220
func runVolcengineArkImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
@@ -1909,7 +1954,7 @@ func validateGenerationInterface(mode string, interfaceType string) error {
}
allowed := map[string]map[string]bool{
"text": {"chat-completion": true, "openai-response": true},
"image": {"openai-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true},
"image": {"openai-image": true, "grok-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true},
"video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true},
"audio": {"openai-audio": true, "async-audio": true},
}
@@ -52,6 +52,18 @@ type xaiVideoImage struct {
URL string `json:"url"`
}
type grokImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Image *grokImageInput `json:"image,omitempty"`
N int `json:"n"`
ResponseFormat string `json:"response_format"`
}
type grokImageInput struct {
URL string `json:"url"`
}
type geminiVeoRequest struct {
Instances []geminiVeoInstance `json:"instances"`
Parameters geminiVeoParameters `json:"parameters"`
+63
View File
@@ -104,6 +104,66 @@ func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) {
}
}
func TestRunGrokImageTaskUsesJSONEditContract(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/images/edits" {
t.Errorf("path = %q, want /v1/images/edits", r.URL.Path)
}
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var body grokImageRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body.Model != "grok-imagine-image-quality" || body.N != 1 || body.ResponseFormat != "url" {
t.Fatalf("request body = %#v", body)
}
if body.Image == nil || body.Image.URL != testReferenceImageDataURL {
t.Fatalf("image = %#v", body.Image)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/result.png"}]}`))
}))
defer server.Close()
result, err := runImageTask(context.Background(), canvasGenerationInput{
Mode: "image",
Prompt: "edit the reference",
Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "grok-imagine-image-quality", InterfaceType: "grok-image"},
ReferenceImages: []providerMedia{{DataURL: testReferenceImageDataURL}},
})
if err != nil {
t.Fatalf("runImageTask() error = %v", err)
}
images, _ := result["images"].([]map[string]string)
if len(images) != 1 || images[0]["dataUrl"] != "https://example.com/result.png" {
t.Fatalf("images = %#v", result["images"])
}
}
func TestGrokImageRequestBodyRejectsMaskAndMultipleReferences(t *testing.T) {
if _, _, err := grokImageRequestBody(canvasGenerationInput{Config: providerConfig{InterfaceType: "grok-image"}, Mask: &providerMedia{DataURL: testReferenceImageDataURL}}); err == nil || !strings.Contains(err.Error(), "不支持蒙版") {
t.Fatalf("mask error = %v", err)
}
if _, _, err := grokImageRequestBody(canvasGenerationInput{Config: providerConfig{InterfaceType: "grok-image"}, ReferenceImages: []providerMedia{{DataURL: testReferenceImageDataURL}, {DataURL: testReferenceImageDataURL}}}); err == nil || !strings.Contains(err.Error(), "只支持 1 张") {
t.Fatalf("multiple reference error = %v", err)
}
}
func TestGrokImageRequestBodyPrefersPublicURL(t *testing.T) {
body, path, err := grokImageRequestBody(canvasGenerationInput{
Config: providerConfig{Model: "grok-imagine-image", InterfaceType: "grok-image"},
ReferenceImages: []providerMedia{{URL: "https://example.com/reference.png", DataURL: testReferenceImageDataURL}},
})
if err != nil {
t.Fatalf("grokImageRequestBody() error = %v", err)
}
if path != "/images/edits" || body.Image == nil || body.Image.URL != "https://example.com/reference.png" {
t.Fatalf("path = %q, image = %#v", path, body.Image)
}
}
func TestNormalizePixelSizeConvertsCanvasAspectRatios(t *testing.T) {
tests := map[string]string{
"1:1": "1024x1024",
@@ -974,6 +1034,9 @@ func TestValidateGenerationInterfaceRejectsMismatchedType(t *testing.T) {
if err := validateGenerationInterface("video", "xai-video"); err != nil {
t.Fatalf("validateGenerationInterface() error = %v", err)
}
if err := validateGenerationInterface("image", "grok-image"); err != nil {
t.Fatalf("validateGenerationInterface() error = %v", err)
}
}
func TestProcessTaskValidatesInterfaceBeforeHydratingMedia(t *testing.T) {