From 6fa29b2e980f236939d7d22670bf90601ddfbbe0 Mon Sep 17 00:00:00 2001 From: a179-sanae Date: Tue, 22 Sep 2026 21:39:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(aistudio):=20=E4=BF=AE=E5=A4=8D=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E6=A8=A1=E5=9E=8B=20GenerateContent=20403=20Code=207?= =?UTF-8?q?=EF=BC=88=E8=A1=A5=E5=85=A8=E5=AE=98=E7=BD=91=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=AD=97=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官网对图像生成模型的 GenerateContent 有一组隐藏扩展约束,经官网页面 XHR 抓包与逐字段消融回放实测,以下每一项缺失/错误都会独立触发 403 Code 7(The caller does not have permission): - responseModalities 必须为 [IMAGE,TEXT],纯 IMAGE 被拒; 含 /v1/images/generations 显式传 [IMAGE] 的路径,统一归一化补 TEXT。 - wire[2] safetySettings:图像模型官网发 null,代理注入的配置会被拒。 - wire[6]:output_resolution 能力的模型必须下发固定值 [[null,null,null,[null,[[]]]]]。 - generationConfig imageConfig:output_resolution 模型缺省需带 [null,"1K"];无该能力的模型(如 2.5-flash-image)发了反而被拒。 - X-Goog-Ext-519733851-Bin:该头按 bootstrap 页面(gemini-flash-latest) 采样,其 proto field15 与图像模型页面取值不同(1 vs 0),错值被拒、 缺省可通过;图像请求改为置空(头合并时被删除)。 能力分闸依据模型目录 capabilities(image_route / output_resolution), NB1 类只要求 modalities,多发扩展字段同样被拒。 已实测:gemini-3-pro-image 文生图、gemini-3.1-flash-image / gemini-2.5-flash-image 图生图、原生 generateContent + imageConfig{1:1,2K} + 参考图均通过。 --- internal/aistudio/client.go | 5 ++++ internal/aistudio/generate.go | 43 ++++++++++++++++++++++++++++++++--- internal/aistudio/models.go | 5 ++++ internal/aistudio/types.go | 2 ++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/internal/aistudio/client.go b/internal/aistudio/client.go index e7353e97..7cd31598 100644 --- a/internal/aistudio/client.go +++ b/internal/aistudio/client.go @@ -150,6 +150,11 @@ func (c *Client) do(ctx context.Context, method string, accountID string, reques func (c *Client) doProtected(ctx context.Context, request GenerateRequest, body []byte) (*RPCResponse, error) { rpc := newRPCRequest("GenerateContent", request.AccountID, request.ID, body, true) c.applyBenefitTier(rpc.Method, request.AccountID, rpc.Header) + if request.ImageRoute { + // 该头随 bootstrap 页面(gemini-flash-latest)采样,图像模型页面取值不同; + // 置空使其在头合并时被删除,官网实测缺省可通过(错值会被 Code 7 拒绝) + rpc.Header["X-Goog-Ext-519733851-Bin"] = nil + } response, err := c.protected.DoProtected(ctx, request, rpc) if err != nil { return nil, fmt.Errorf("发送 AI Studio GenerateContent: %w", err) diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index accc9531..6a5cc958 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -42,13 +42,19 @@ func EncodeGenerateContentRequest(request GenerateRequest, defaults GenerationDe wire := make([]any, length) wire[0] = wireModelName(request.Model) wire[1] = contents - wire[2] = observedSafetySettings() + if !defaults.ImageRoute { + wire[2] = observedSafetySettings() + } wire[3] = config if request.System != "" { wire[5] = encodeSystemInstruction(request.System) } - if explicitTools { + switch { + case explicitTools: wire[6] = tools + case defaults.OutputResolution: + // 官网对可设置分辨率的图像模型固定下发该槽位;缺失会被上游以 Code 7 拒绝 + wire[6] = []any{[]any{nil, nil, nil, []any{nil, []any{}}}} } wire[10] = int64(1) if runtime.Timezone != "" { @@ -134,6 +140,13 @@ func encodeGenerationConfig(config GenerationConfig, defaults GenerationDefaults return nil, err } imageConfig := encodeImageConfig(config.ImageConfig) + if defaults.OutputResolution && imageConfig == nil { + // 官网对可设置分辨率的图像模型固定带 imageConfig;缺失会被上游以 Code 7 拒绝 + imageConfig = []any{nil, "1K"} + } + if !defaults.OutputResolution { + imageConfig = nil + } speechConfig, err := encodeSpeechConfig(config.SpeechConfig) if err != nil { return nil, err @@ -330,6 +343,11 @@ func encodeSpeechConfig(config *SpeechConfig) ([]any, error) { } func applyModelMediaDefaults(config GenerationConfig, model Model) GenerationConfig { + if model.Capabilities["image_route"] && imageModalityNeedsText(config.ResponseModalities) { + // 官网对图像模型总是下发 [IMAGE, TEXT];纯 IMAGE 会被上游以 Code 7 拒绝。 + config.ResponseModalities = []ResponseModality{ResponseModalityImage, ResponseModalityText} + return config + } if config.ResponseModalities != nil { return config } @@ -337,11 +355,29 @@ func applyModelMediaDefaults(config GenerationConfig, model Model) GenerationCon case model.Capabilities["speech_route"], model.Capabilities["music_route"]: config.ResponseModalities = []ResponseModality{ResponseModalityAudio} case model.Capabilities["image_route"]: - config.ResponseModalities = []ResponseModality{ResponseModalityImage} + config.ResponseModalities = []ResponseModality{ResponseModalityImage, ResponseModalityText} } return config } +// imageModalityNeedsText 判断图像模型请求是否缺 TEXT 模态 +func imageModalityNeedsText(modalities []ResponseModality) bool { + if modalities == nil { + return true + } + hasImage := false + hasText := false + for _, modality := range modalities { + switch ResponseModality(strings.ToUpper(strings.TrimSpace(string(modality)))) { + case ResponseModalityImage: + hasImage = true + case ResponseModalityText: + hasText = true + } + } + return hasImage && !hasText +} + func applySpeechTranscript(contents []Content, model Model, config GenerationConfig) []Content { if !model.Capabilities["speech_route"] || config.SpeechConfig == nil { return contents @@ -382,6 +418,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return nil, fmt.Errorf("%w: %v", ErrInvalidArgument, err) } request.Config = applyModelMediaDefaults(request.Config, entry.model) + request.ImageRoute = entry.defaults.ImageRoute request.Contents = applySpeechTranscript(request.Contents, entry.model, request.Config) runtime := RequestContext{} if c.contextProvider != nil { diff --git a/internal/aistudio/models.go b/internal/aistudio/models.go index 7e1cc0dd..6fc3ad97 100644 --- a/internal/aistudio/models.go +++ b/internal/aistudio/models.go @@ -60,6 +60,10 @@ type GenerationDefaults struct { ThinkingLevel bool DefaultThinkingLevel int64 ThinkingLevels []int64 + // ImageRoute 表示图像生成模型(官网 GenerateContent 走扩展约束) + ImageRoute bool + // OutputResolution 表示支持输出分辨率(官网会带 imageConfig 与扩展槽位) + OutputResolution bool } type modelEntry struct { @@ -253,6 +257,7 @@ func decodeGenerationDefaults(row []json.RawMessage, path string, evidence json. defaults := GenerationDefaults{ Thinking: capabilities["thinking"], ThinkingBudget: capabilities["thinking_budget"], ThinkingLevel: capabilities["thinking_level"], DefaultThinkingLevel: 2, + ImageRoute: capabilities["image_route"], OutputResolution: capabilities["output_resolution"], } maxOutput, err := optionalIntField(row, 6, path, evidence) if err != nil { diff --git a/internal/aistudio/types.go b/internal/aistudio/types.go index 3dd7a814..fe43bc9b 100644 --- a/internal/aistudio/types.go +++ b/internal/aistudio/types.go @@ -224,6 +224,8 @@ type GenerateRequest struct { Config GenerationConfig `json:"config,omitempty"` Tools Tools `json:"tools,omitempty"` AccountID string `json:"account_id,omitempty"` + // ImageRoute 内部标记:图像生成模型(由模型目录能力推导) + ImageRoute bool `json:"-"` } // TokenCountRequest 表示计数请求