diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 7a23984d90..6b81b9fd9f 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1020,7 +1020,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { } if len(availableModels) > 0 { - writeModelsList(c, availableModels) + writeModelsList(c, platform, availableModels) return } @@ -1040,6 +1040,10 @@ func (h *GatewayHandler) Models(c *gin.Context) { }) return } + if platform == service.PlatformGrok { + writeGrokModelsList(c, xai.DefaultModelIDs()) + return + } c.JSON(http.StatusOK, gin.H{ "object": "list", @@ -1047,7 +1051,11 @@ func (h *GatewayHandler) Models(c *gin.Context) { }) } -func writeModelsList(c *gin.Context, modelIDs []string) { +func writeModelsList(c *gin.Context, platform string, modelIDs []string) { + if platform == service.PlatformGrok { + writeGrokModelsList(c, modelIDs) + return + } models := make([]claude.Model, 0, len(modelIDs)) for _, modelID := range modelIDs { models = append(models, claude.Model{ @@ -1068,7 +1076,66 @@ func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) { writeOpenAIModelsList(c, modelIDs) return } - writeModelsList(c, modelIDs) + writeModelsList(c, platform, modelIDs) +} + +type grokReasoningEffortOption struct { + Value string `json:"value"` + Label string `json:"label"` + Default bool `json:"default,omitempty"` +} + +type grokModelListItem struct { + xai.Model + SupportsReasoningEffort bool `json:"supportsReasoningEffort,omitempty"` + ReasoningEffort string `json:"reasoningEffort,omitempty"` + ReasoningEfforts []grokReasoningEffortOption `json:"reasoningEfforts,omitempty"` +} + +func writeGrokModelsList(c *gin.Context, modelIDs []string) { + defaults := xai.DefaultModels() + defaultsByID := make(map[string]xai.Model, len(defaults)) + for _, model := range defaults { + defaultsByID[model.ID] = model + } + + models := make([]grokModelListItem, 0, len(modelIDs)) + for _, modelID := range modelIDs { + model, ok := defaultsByID[modelID] + if !ok { + model = xai.Model{ + ID: modelID, + Object: "model", + OwnedBy: "xai", + DisplayName: modelID, + } + } + item := grokModelListItem{Model: model} + if grokModelSupportsConfigurableReasoning(modelID) { + item.SupportsReasoningEffort = true + item.ReasoningEffort = "high" + item.ReasoningEfforts = []grokReasoningEffortOption{ + {Value: "low", Label: "Low"}, + {Value: "medium", Label: "Medium"}, + {Value: "high", Label: "High", Default: true}, + } + } + models = append(models, item) + } + + c.JSON(http.StatusOK, gin.H{ + "object": "list", + "data": models, + }) +} + +func grokModelSupportsConfigurableReasoning(modelID string) bool { + switch strings.ToLower(strings.TrimSpace(modelID)) { + case "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1": + return true + default: + return false + } } func writeOpenAIModelsList(c *gin.Context, modelIDs []string) { diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 6011e13027..30a8b5463f 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -25,11 +25,20 @@ type gatewayModelsResponseForTest struct { } type gatewayModelItemForTest struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - OwnedBy string `json:"owned_by"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + CreatedAt string `json:"created_at"` + SupportsReasoningEffort bool `json:"supportsReasoningEffort"` + ReasoningEffort string `json:"reasoningEffort"` + ReasoningEfforts []gatewayReasoningEffortOptionForTest `json:"reasoningEfforts"` +} + +type gatewayReasoningEffortOptionForTest struct { + Value string `json:"value"` + Label string `json:"label"` + Default bool `json:"default"` } func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) { @@ -84,6 +93,50 @@ func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) { require.NotContains(t, modelIDsForTest(got.Data), "claude-sonnet-4-6") } +func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(4409) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.5": "grok-4.5"}, + }, + }, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformGrok}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Len(t, got.Data, 1) + model := got.Data[0] + require.Equal(t, "grok-4.5", model.ID) + require.True(t, model.SupportsReasoningEffort) + require.Equal(t, "high", model.ReasoningEffort) + require.Equal(t, []gatewayReasoningEffortOptionForTest{ + {Value: "low", Label: "Low"}, + {Value: "medium", Label: "Medium"}, + {Value: "high", Label: "High", Default: true}, + }, model.ReasoningEfforts) +} + func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 06113efc6c..8e3cf60bb1 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -2,6 +2,7 @@ package repository import ( "bufio" + "bytes" "compress/flate" "compress/gzip" "context" @@ -73,8 +74,10 @@ const ( // client version. Keep a known-good stable version in the binary while // allowing operators to bump it without waiting for a Sub2API release. grokCLIProxyHost = "cli-chat-proxy.grok.com" + grokOfficialAPIHost = "api.x.ai" grokCLIStableVersion = "0.2.93" grokCLIVersionOverride = "XAI_GROK_CLI_VERSION" + grokFallbackBodyLimit = 64 << 10 ) const ( @@ -195,7 +198,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i } // 执行请求 - resp, err := servertiming.Do(httpClientForUpstreamRequest(entry.client, req), req) + client := httpClientForUpstreamRequest(entry.client, req) + client = httpClientWithGrokAccessDeniedFallback(client) + resp, err := servertiming.Do(client, req) if err != nil { s.recordOpenAIHTTP2Failure(profile, entry.protocolMode, entry.proxyKey, err) // 请求失败,立即减少计数 @@ -257,7 +262,9 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return nil, err } - resp, err := servertiming.Do(httpClientForUpstreamRequest(entry.client, req), req) + client := httpClientForUpstreamRequest(entry.client, req) + client = httpClientWithGrokAccessDeniedFallback(client) + resp, err := servertiming.Do(client, req) if err != nil { atomic.AddInt64(&entry.inFlight, -1) atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) @@ -286,6 +293,129 @@ func httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http. return &clone } +// grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as +// the primary OAuth route, but retries a replayable request against api.x.ai +// when the proxy returns its compatibility-specific 403 "Access denied". +// Trial subscriptions can hit this boundary while the same OAuth credential +// remains valid on the official API. Other entitlement failures stay on the +// original response so account scheduling semantics do not change. +type grokAccessDeniedFallbackTransport struct { + base http.RoundTripper +} + +func httpClientWithGrokAccessDeniedFallback(client *http.Client) *http.Client { + if client == nil { + return nil + } + clone := *client + base := clone.Transport + if base == nil { + base = http.DefaultTransport + } + clone.Transport = &grokAccessDeniedFallbackTransport{base: base} + return &clone +} + +func (t *grokAccessDeniedFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base.RoundTrip(req) + if err != nil || !isGrokCLIAccessDeniedFallbackCandidate(req, resp) { + return resp, err + } + + body, ok := bufferSmallResponseBody(resp, grokFallbackBodyLimit) + if !ok || !bytes.Contains(bytes.ToLower(body), []byte("access denied")) { + return resp, nil + } + + fallbackReq, err := newGrokOfficialAPIFallbackRequest(req) + if err != nil { + return resp, nil + } + fallbackResp, fallbackErr := t.base.RoundTrip(fallbackReq) + if fallbackErr != nil { + slog.Debug("grok_cli_access_denied_api_fallback_failed", "path", req.URL.EscapedPath(), "error", fallbackErr) + return resp, nil + } + if fallbackResp.StatusCode < http.StatusOK || fallbackResp.StatusCode >= http.StatusMultipleChoices { + if fallbackResp.Body != nil { + _ = fallbackResp.Body.Close() + } + return resp, nil + } + + if resp.Body != nil { + _ = resp.Body.Close() + } + slog.Warn("grok_cli_access_denied_api_fallback_succeeded", "method", req.Method, "path", req.URL.EscapedPath()) + return fallbackResp, nil +} + +func isGrokCLIAccessDeniedFallbackCandidate(req *http.Request, resp *http.Response) bool { + return req != nil && req.URL != nil && req.GetBody != nil && resp != nil && + resp.StatusCode == http.StatusForbidden && + strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) && + strings.EqualFold(strings.TrimSpace(req.Header.Get("X-XAI-Token-Auth")), "xai-grok-cli") && + strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.Header.Get("Authorization"))), "bearer ") +} + +func newGrokOfficialAPIFallbackRequest(req *http.Request) (*http.Request, error) { + body, err := req.GetBody() + if err != nil { + return nil, err + } + fallbackReq := req.Clone(req.Context()) + fallbackReq.Body = body + fallbackReq.URL = cloneURL(req.URL) + fallbackReq.URL.Scheme = "https" + fallbackReq.URL.Host = grokOfficialAPIHost + fallbackReq.Host = "" + fallbackReq.RequestURI = "" + fallbackReq.Header = req.Header.Clone() + for _, header := range []string{ + "X-XAI-Token-Auth", + "X-Grok-Client-Version", + "X-Grok-Client-Surface", + "X-UserID", + "X-Email", + "User-Agent", + } { + fallbackReq.Header.Del(header) + } + return fallbackReq, nil +} + +func cloneURL(value *url.URL) *url.URL { + if value == nil { + return nil + } + clone := *value + return &clone +} + +func bufferSmallResponseBody(resp *http.Response, limit int64) ([]byte, bool) { + if resp == nil || resp.Body == nil || limit <= 0 { + return nil, false + } + original := resp.Body + body, err := io.ReadAll(io.LimitReader(original, limit+1)) + if err != nil || int64(len(body)) > limit { + resp.Body = &prefixedReadCloser{ + Reader: io.MultiReader(bytes.NewReader(body), original), + Closer: original, + } + return nil, false + } + _ = original.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + return body, true +} + +type prefixedReadCloser struct { + io.Reader + io.Closer +} + // applyGrokCLIProxyHeaders applies the official Grok Build client identity at // the final shared transport boundary. Keying this behavior to the exact CLI // proxy host keeps direct api.x.ai traffic unchanged and automatically covers diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 151e227146..77749b7a0d 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -1,6 +1,7 @@ package repository import ( + "bytes" "encoding/binary" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "sync/atomic" "testing" "time" @@ -240,6 +242,100 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) } } +func TestHTTPUpstreamDoFallsBackToOfficialGrokAPIOnCLIAccessDenied(t *testing.T) { + upstream := NewHTTPUpstream(nil) + svc, ok := upstream.(*httpUpstreamService) + require.True(t, ok) + + const accountID int64 = 4421 + isolation := svc.getIsolationMode() + profile := service.HTTPUpstreamProfileDefault + proxyKey := directProxyKey + protocolMode := svc.resolveProtocolMode(profile, proxyKey, nil) + settings := svc.resolvePoolSettings(isolation, 1) + settings = svc.applyProfilePoolSettings(settings, profile) + cacheKey := buildCacheKey(isolation, proxyKey, accountID, protocolMode) + + payload := []byte(`{"model":"grok-4.5","input":"hello"}`) + var calls int + var fallbackBody []byte + var fallbackHeaders http.Header + svc.clients[cacheKey] = &upstreamClientEntry{ + client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + if calls == 1 { + require.Equal(t, grokCLIProxyHost, req.URL.Hostname()) + require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth")) + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"Access denied"}`)), + Request: req, + }, nil + } + + fallbackBody = body + fallbackHeaders = req.Header.Clone() + require.Equal(t, grokOfficialAPIHost, req.URL.Hostname()) + require.Equal(t, "/v1/responses", req.URL.Path) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":"response-ok"}`)), + Request: req, + }, nil + })}, + proxyKey: proxyKey, + poolKey: buildPoolKey(settings, protocolMode), + protocolMode: protocolMode, + } + + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", bytes.NewReader(payload)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer oauth-token") + + resp, err := svc.Do(req, "", accountID, 1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + responseBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.JSONEq(t, `{"id":"response-ok"}`, string(responseBody)) + require.Equal(t, 2, calls) + require.Equal(t, payload, fallbackBody) + require.Equal(t, "Bearer oauth-token", fallbackHeaders.Get("Authorization")) + require.Empty(t, fallbackHeaders.Get("X-XAI-Token-Auth")) + require.Empty(t, fallbackHeaders.Get("x-grok-client-version")) + require.Empty(t, fallbackHeaders.Get("User-Agent")) +} + +func TestHTTPUpstreamDoDoesNotFallbackForGrokEntitlementDenial(t *testing.T) { + transport := &grokAccessDeniedFallbackTransport{ + base: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"subscription required"}`)), + Request: req, + }, nil + }), + } + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", strings.NewReader(`{"model":"grok-4.5"}`)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer oauth-token") + req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli") + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.JSONEq(t, `{"error":"subscription required"}`, string(body)) +} + func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Run("uses pinned stable version for the CLI proxy", func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "") diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 1dfd5732d9..701e14d742 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1298,13 +1298,20 @@ func (a *Account) GetGrokBaseURL() string { } // GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs. -// It currently resolves the same way as text traffic; the separate accessor -// preserves the media/text distinction at call sites. +// The subscription CLI gateway enforces a small request-body limit that +// rejects large Base64 media payloads, so OAuth media leaves for api.x.ai +// whenever text traffic resolves to the CLI gateway. Every other manually +// selected endpoint (official/regional API hosts or custom relays) serves +// media as-is. func (a *Account) GetGrokMediaBaseURL() string { if !a.IsGrok() { return "" } - return a.GetGrokBaseURL() + baseURL := a.GetGrokBaseURL() + if a.IsGrokOAuth() && isGrokCLIProxyTarget(baseURL) { + return xai.DefaultBaseURL + } + return baseURL } func (a *Account) GetGrokAccessToken() string { diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go index 9883525f3b..2d6ead7090 100644 --- a/backend/internal/service/account_base_url_test.go +++ b/backend/internal/service/account_base_url_test.go @@ -274,23 +274,23 @@ func TestGetGrokBaseURLHonorsOAuthCustomRegardlessOfUnsafeOverrides(t *testing.T require.Equal(t, "https://custom.example.com/v1", account.GetGrokBaseURL()) } -func TestGetGrokMediaBaseURLFollowsTextTrafficResolution(t *testing.T) { +func TestGetGrokMediaBaseURLRedirectsCLIGatewayToOfficialAPI(t *testing.T) { tests := []struct { name string account Account expected string }{ { - name: "oauth without base_url uses CLI subscription proxy", + name: "oauth without base_url uses official media API", account: Account{ Type: AccountTypeOAuth, Platform: PlatformGrok, Credentials: map[string]any{}, }, - expected: xai.DefaultCLIBaseURL, + expected: xai.DefaultBaseURL, }, { - name: "oauth stored CLI proxy stays on CLI subscription proxy", + name: "oauth stored CLI proxy is separated from the media API", account: Account{ Type: AccountTypeOAuth, Platform: PlatformGrok, @@ -298,7 +298,29 @@ func TestGetGrokMediaBaseURLFollowsTextTrafficResolution(t *testing.T) { "base_url": xai.DefaultCLIBaseURL, }, }, - expected: xai.DefaultCLIBaseURL, + expected: xai.DefaultBaseURL, + }, + { + name: "oauth stored CLI proxy variant is canonicalized to the media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "HTTPS://CLI-CHAT-PROXY.GROK.COM:443/%76%31/", + }, + }, + expected: xai.DefaultBaseURL, + }, + { + name: "oauth unparseable base_url falls back to official media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "not a url", + }, + }, + expected: xai.DefaultBaseURL, }, { name: "oauth stored official API endpoint is honored (manual endpoint switch)", @@ -311,6 +333,17 @@ func TestGetGrokMediaBaseURLFollowsTextTrafficResolution(t *testing.T) { }, expected: xai.DefaultBaseURL, }, + { + name: "oauth stored regional API endpoint is honored for media", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://us-west-2.api.x.ai/v1", + }, + }, + expected: "https://us-west-2.api.x.ai/v1", + }, { name: "oauth custom base_url redirects media traffic", account: Account{ diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index a70622da5c..457877cb8d 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -9,6 +9,7 @@ import ( "mime" "mime/multipart" "net/http" + "net/url" "strconv" "strings" "time" @@ -319,7 +320,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( } upstreamReq.Header.Set("Authorization", "Bearer "+token) upstreamReq.Header.Set("Accept", "application/json") - if account.IsGrokOAuth() { + if account.IsGrokOAuth() && isGrokCLIProxyTarget(targetURL) { applyGrokCLIHeaders(upstreamReq.Header) } if endpoint.RequiresRequestBody() { @@ -376,6 +377,11 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( }, nil } +func isGrokCLIProxyTarget(rawURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + return err == nil && strings.EqualFold(parsed.Hostname(), "cli-chat-proxy.grok.com") +} + func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { if endpoint != GrokMediaEndpointImagesEdits || gjson.ValidBytes(body) { return body, contentType, nil diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 1b605ae818..71e04d867e 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -666,7 +666,7 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) require.Equal(t, VideoBillingDefaultDurationSeconds, result.VideoDurationSeconds) } -func TestForwardGrokMediaOAuthImageToVideoKeepsCLIGatewayForLargeBody(t *testing.T) { +func TestForwardGrokMediaOAuthImageToVideoUsesOfficialAPIForLargeBody(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() @@ -702,7 +702,9 @@ func TestForwardGrokMediaOAuthImageToVideoKeepsCLIGatewayForLargeBody(t *testing _, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") require.NoError(t, err) - require.Equal(t, xai.DefaultCLIBaseURL+"/videos/generations", upstream.lastReq.URL.String()) + require.Equal(t, xai.DefaultBaseURL+"/videos/generations", upstream.lastReq.URL.String()) + require.Empty(t, upstream.lastReq.Header.Get("X-XAI-Token-Auth")) + require.Empty(t, upstream.lastReq.Header.Get("x-grok-client-version")) require.Equal(t, "data:image/png;base64,"+imageData, gjson.GetBytes(upstream.lastBody, "image.image_url").String()) }