fix(media): 支持大尺寸图像响应并补齐迁移契约
- 为图像协议放宽 JSON 响应上限并显式处理读取与超限错误 - 合并基础模型与平台能力,避免运行时丢失分辨率和比例约束 - 固化稳定 Gemini 图像模型的能力、计价和 preview 兼容别名
This commit is contained in:
@@ -13,6 +13,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxJSONResponseBytes int64 = 16 << 20
|
||||
mediaMaxJSONResponseBytes int64 = 128 << 20
|
||||
)
|
||||
|
||||
func credential(candidate map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := candidate[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
@@ -50,7 +55,12 @@ func decodeHTTPResponse(resp *http.Response) (map[string]any, error) {
|
||||
|
||||
func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[string]any, *WireResponse, error) {
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
||||
maxBytes := maxJSONResponseBytesForProtocol(protocol)
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
tooLarge := int64(len(raw)) > maxBytes
|
||||
if tooLarge {
|
||||
raw = raw[:maxBytes]
|
||||
}
|
||||
wire := &WireResponse{
|
||||
Protocol: strings.TrimSpace(protocol),
|
||||
StatusCode: resp.StatusCode,
|
||||
@@ -60,6 +70,26 @@ func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[st
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &wire.Body)
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, wire, &ClientError{
|
||||
Code: "response_read_error",
|
||||
Message: readErr.Error(),
|
||||
StatusCode: resp.StatusCode,
|
||||
RequestID: requestIDFromHTTPResponse(resp),
|
||||
Retryable: true,
|
||||
Wire: wire,
|
||||
}
|
||||
}
|
||||
if tooLarge {
|
||||
return nil, wire, &ClientError{
|
||||
Code: "response_too_large",
|
||||
Message: fmt.Sprintf("upstream JSON response exceeds %d bytes", maxBytes),
|
||||
StatusCode: resp.StatusCode,
|
||||
RequestID: requestIDFromHTTPResponse(resp),
|
||||
Retryable: false,
|
||||
Wire: wire,
|
||||
}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, wire, &ClientError{
|
||||
Code: statusCodeName(resp.StatusCode),
|
||||
@@ -82,6 +112,15 @@ func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[st
|
||||
return out, wire, nil
|
||||
}
|
||||
|
||||
func maxJSONResponseBytesForProtocol(protocol string) int64 {
|
||||
switch strings.TrimSpace(protocol) {
|
||||
case ProtocolGeminiGenerateContent, ProtocolOpenAIImages:
|
||||
return mediaMaxJSONResponseBytes
|
||||
default:
|
||||
return defaultMaxJSONResponseBytes
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleResponseHeaders(source http.Header) map[string][]string {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -46,6 +46,46 @@ func TestDecodeHTTPResponseForProtocolCapturesOfficialErrorWire(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHTTPResponseForProtocolAllowsLargeImagePayload(t *testing.T) {
|
||||
payload := `{"data":"` + strings.Repeat("a", int(defaultMaxJSONResponseBytes)) + `"}`
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(payload)),
|
||||
}
|
||||
|
||||
result, wire, err := decodeHTTPResponseForProtocol(response, ProtocolGeminiGenerateContent)
|
||||
if err != nil {
|
||||
t.Fatalf("large Gemini image response failed: %v", err)
|
||||
}
|
||||
if got, _ := result["data"].(string); len(got) != int(defaultMaxJSONResponseBytes) {
|
||||
t.Fatalf("large Gemini image payload length = %d", len(got))
|
||||
}
|
||||
if wire == nil || len(wire.RawJSON) != len(payload) {
|
||||
t.Fatalf("large Gemini wire payload was truncated: %+v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHTTPResponseForProtocolRejectsOversizedDefaultPayload(t *testing.T) {
|
||||
payload := `{"data":"` + strings.Repeat("a", int(defaultMaxJSONResponseBytes)) + `"}`
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(payload)),
|
||||
}
|
||||
|
||||
_, _, err := decodeHTTPResponseForProtocol(response, ProtocolOpenAIResponses)
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized default response to fail")
|
||||
}
|
||||
clientErr, ok := err.(*ClientError)
|
||||
if !ok || clientErr.Code != "response_too_large" {
|
||||
t.Fatalf("unexpected oversized response error: %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiNativeStreamPreservesOfficialEvents(t *testing.T) {
|
||||
var requestedPath string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -43,12 +43,12 @@ func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType
|
||||
CASE
|
||||
WHEN b.capabilities #> '{text_generate,supportedApiProtocols}' IS NOT NULL
|
||||
THEN jsonb_set(
|
||||
COALESCE(m.capabilities, '{}'::jsonb),
|
||||
COALESCE(b.capabilities, '{}'::jsonb) || COALESCE(m.capabilities, '{}'::jsonb),
|
||||
'{text_generate,supportedApiProtocols}',
|
||||
b.capabilities #> '{text_generate,supportedApiProtocols}',
|
||||
true
|
||||
)
|
||||
ELSE m.capabilities
|
||||
ELSE COALESCE(b.capabilities, '{}'::jsonb) || COALESCE(m.capabilities, '{}'::jsonb)
|
||||
END AS effective_capabilities,
|
||||
m.capability_override,
|
||||
COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override,
|
||||
|
||||
Reference in New Issue
Block a user