diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 1e8e249e..fe5e0cc9 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -195,6 +195,11 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} } + if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr { + err = statusErr{code: http.StatusBadGateway, msg: errPayload} + reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(wsResp.Body), err) + return cliproxyexecutor.Response{}, err + } reporter.Publish(ctx, helps.ParseGeminiUsage(wsResp.Body)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) @@ -310,7 +315,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth } return nil, statusErr{code: firstEvent.Status, msg: body.String()} } - out := make(chan cliproxyexecutor.StreamChunk) + out := make(chan cliproxyexecutor.StreamChunk, 1) go func(first wsrelay.StreamEvent) { defer close(out) defer cancelStream() @@ -323,15 +328,28 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, body.toFormat, responseFormat, originalRequest) var streamUsage helps.StreamUsageBuffer defer streamUsage.Publish(ctx, reporter) + var streamErr error + defer func() { + _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) + }() var param any helps.InitializeApplyPatchStream(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, ¶m) metadataLogged := false finishStream := func() { - if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) || ctx.Err() != nil { + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return + } + if ctx.Err() != nil { return } lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, []byte("[DONE]"), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for _, line := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(line)}: @@ -340,9 +358,13 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth } } _ = helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) } processEvent := func(event wsrelay.StreamEvent) bool { if event.Err != nil { + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return false + } helps.RecordAPIResponseError(ctx, e.cfg, event.Err) reporter.PublishFailure(ctx, event.Err) select { @@ -362,23 +384,30 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth if len(event.Payload) > 0 { reporter.MarkFirstResponseByte() helps.AppendAPIResponseChunk(ctx, e.cfg, event.Payload) - reporter.ObserveResponseModel(event.Payload) - filtered := helps.FilterSSEUsageMetadata(event.Payload) - if detail, ok := helps.ParseGeminiStreamUsage(filtered); ok { - streamUsage.Observe(detail, true) - } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, filtered, ¶m, claudeInputTokens) - helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) - for i := range lines { - select { - case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}: - case <-ctx.Done(): + // A relay chunk may hold several SSE frames; the translators read one frame per call (gjson parses the first + // JSON object only), so each frame goes through the whole per-frame path: model, usage, translation, errors. + for _, frame := range splitRelayFrames(event.Payload) { + reporter.ObserveResponseModel(frame) + if detail, ok := helps.ParseGeminiStreamUsage(frame); ok { + streamUsage.Observe(detail, true) + } + filtered := helps.FilterSSEUsageMetadata(frame) + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, filtered, ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) + for i := range lines { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}: + case <-ctx.Done(): + return false + } + } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return false } } - if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { - return false - } break } case wsrelay.MessageTypeStreamEnd: @@ -404,8 +433,13 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth } return false } + reporter.ObserveResponseModel(event.Payload) + streamUsage.Observe(helps.ParseGeminiUsage(event.Payload), true) lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, event.Payload, ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}: @@ -417,10 +451,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth return false } finishStream() - reporter.ObserveResponseModel(event.Payload) - streamUsage.Observe(helps.ParseGeminiUsage(event.Payload), true) return false case wsrelay.MessageTypeError: + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return false + } helps.RecordAPIResponseError(ctx, e.cfg, event.Err) reporter.PublishFailure(ctx, event.Err) select { @@ -440,6 +475,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth } } _ = helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) }(firstEvent) return &cliproxyexecutor.StreamResult{Headers: firstEvent.Headers.Clone(), Chunks: out}, nil } @@ -460,10 +496,6 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A return cliproxyexecutor.Response{}, err } - body.payload, _ = sjson.DeleteBytes(body.payload, "generationConfig") - body.payload, _ = sjson.DeleteBytes(body.payload, "tools") - body.payload, _ = sjson.DeleteBytes(body.payload, "safetySettings") - endpoint := e.buildEndpoint(baseModel, "countTokens", "") wsReq := &wsrelay.HTTPRequest{ Method: http.MethodPost, @@ -534,15 +566,17 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe } originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) - payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) - payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, false) + if err != nil { + return nil, translatedPayload{}, err + } + payload, err = helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err } payload = fixGeminiImageAspectRatio(baseModel, payload) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - payload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", payload, originalTranslated, requestedModel, requestPath, opts.Headers) payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema") @@ -562,6 +596,12 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe payload = helps.EnsureGeminiTrailingUserContent(payload, "contents") } payload = normalizeAIStudioThinkingLevel(payload) + if action == "countTokens" { + payload, _ = sjson.DeleteBytes(payload, "generationConfig") + payload, _ = sjson.DeleteBytes(payload, "tools") + payload, _ = sjson.DeleteBytes(payload, "safetySettings") + } + payload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", payload, originalTranslated, requestedModel, requestPath, opts.Headers) return payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil } @@ -606,6 +646,42 @@ func (e *AIStudioExecutor) buildEndpoint(model, action, alt string) string { return base } +// splitRelayFrames splits one relay stream chunk into the SSE frames it holds. A chunk that starts with an SSE field (data:, event:, +// id:, retry: or a comment) is cut at its blank lines and each frame is reduced to its data: lines (the translators read data +// lines; event:, id:, retry: and comment lines carry nothing they use, and a frame without data, a keep-alive comment, is dropped), +// each frame ending with the blank line. A chunk that starts anything else (a bare or pretty-printed JSON value, "[DONE]") is one +// frame as it is, never cut. +func splitRelayFrames(payload []byte) [][]byte { + trimmed := bytes.TrimLeft(payload, " \t\r\n") + if !startsWithSSEField(trimmed) { + return [][]byte{payload} + } + normalized := bytes.ReplaceAll(payload, []byte("\r\n"), []byte("\n")) + var frames [][]byte + for _, block := range bytes.Split(normalized, []byte("\n\n")) { + var frame []byte + for _, line := range bytes.Split(block, []byte("\n")) { + if bytes.HasPrefix(bytes.TrimLeft(line, " \t"), []byte("data:")) { + frame = append(frame, bytes.TrimLeft(line, " \t")...) + frame = append(frame, '\n') + } + } + if len(frame) > 0 { + frames = append(frames, append(frame, '\n')) + } + } + return frames +} + +func startsWithSSEField(b []byte) bool { + for _, prefix := range []string{"data:", "event:", "id:", "retry:", ":"} { + if bytes.HasPrefix(b, []byte(prefix)) { + return true + } + } + return false +} + // ensureColonSpacedJSON normalizes JSON objects so that colons are followed by a single space while // keeping the payload otherwise compact. Non-JSON inputs are returned unchanged. func ensureColonSpacedJSON(payload []byte) []byte { diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 9a53090c..25118cfe 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -23,6 +23,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport" "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil" sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" log "github.com/sirupsen/logrus" @@ -41,7 +42,7 @@ const ( antigravityClientSecret = "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" antigravityAuthType = "antigravity" antigravityRequestTokenSafetyWindow = 5 * time.Minute - antigravityCreditsHintRefreshInterval = 10 * time.Minute + antigravityCreditsHintRefreshInterval = cliproxyauth.AntigravityCreditsHintRefreshInterval antigravityCreditsHintRefreshTimeout = 5 * time.Second antigravityShortQuotaCooldownThreshold = 5 * time.Minute antigravityInstantRetryThreshold = 3 * time.Second @@ -436,7 +437,8 @@ func antigravityCredentialScope(prefix, secret string) string { // negotiates TLS 1.3 without advertising an ALPN protocol and therefore never uses h2. // The underlying Transport is always shared so keep-alive connections survive across // requests instead of forcing a fresh TCP + TLS handshake every time. -func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { +func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) { + defer func() { clientOut = httptransport.Decorate(ctx, clientOut) }() // Native Antigravity reuses one transport across requests. Opt into a // credential-scoped proxy transport only here so other providers keep their // existing lifecycle and different OAuth identities remain isolated. @@ -448,7 +450,7 @@ func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cli // context transport fallback, preserving the previous behavior. } - client := helps.NewProxyAwareHTTPClient(ctx, cfg, auth, timeout) + client := helps.NewProxyAwareHTTPClient(httptransport.WithoutDecorator(ctx), cfg, auth, timeout) // Direct requests share an HTTP/1.1 pool only within the selected credential. if client.Transport == nil { client.Transport = antigravityHTTP11Transport(auth, antigravityBaseTransport, cfg) @@ -517,11 +519,14 @@ func ensureAntigravityGeminiTrailingUserContent(modelName string, payload []byte } // ensureAntigravityGeminiBoundaryUserContent normalizes both leading and trailing -// turns for Gemini targets. Claude targets are left unchanged. +// turns for Gemini targets. A trailing reasoning-only model turn is dropped first +// so the request does not rely only on a synthetic empty user turn (#6530). +// Claude targets are left unchanged. func ensureAntigravityGeminiBoundaryUserContent(modelName string, payload []byte) []byte { if strings.Contains(strings.ToLower(modelName), "claude") { return payload } + payload = helps.DropGeminiTrailingThoughtOnlyContent(payload, "request.contents") return helps.EnsureGeminiBoundaryUserContent(payload, "request.contents") } diff --git a/internal/runtime/executor/antigravity_executor_credits.go b/internal/runtime/executor/antigravity_executor_credits.go index 51645397..193f4e1a 100644 --- a/internal/runtime/executor/antigravity_executor_credits.go +++ b/internal/runtime/executor/antigravity_executor_credits.go @@ -247,7 +247,7 @@ func decideAntigravity429(body []byte) antigravity429Decision { reason := strings.TrimSpace(detail.Get("reason").String()) decision.reason = reason switch { - case strings.EqualFold(reason, "QUOTA_EXHAUSTED"): + case strings.EqualFold(reason, "QUOTA_EXHAUSTED"), strings.EqualFold(reason, "INSUFFICIENT_G1_CREDITS_BALANCE"): decision.kind = antigravity429DecisionFullQuotaExhausted return decision case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"): @@ -289,7 +289,13 @@ func clearAntigravityCreditsFailureState(auth *cliproxyauth.Auth) { if auth == nil || strings.TrimSpace(auth.ID) == "" { return } - antigravityCreditsFailureByAuth.Delete(strings.TrimSpace(auth.ID)) + authID := strings.TrimSpace(auth.ID) + _, hadFailure := antigravityCreditsFailureByAuth.LoadAndDelete(authID) + if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && hint.Known && !hint.Available { + if hadFailure || !cliproxyauth.IsAntigravityCreditsHintFresh(hint, time.Now()) { + cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{}) + } + } } func markAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) { if auth == nil || strings.TrimSpace(auth.ID) == "" || antigravityCoolingDisabled(auth, nil) { @@ -363,7 +369,7 @@ func (e *AntigravityExecutor) maybeRefreshAntigravityCreditsHint(ctx context.Con if authID == "" { return } - if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && hint.Known { + if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && cliproxyauth.IsAntigravityCreditsHintFresh(hint, time.Now()) { return } if strings.TrimSpace(accessToken) == "" { @@ -516,7 +522,7 @@ func (e *AntigravityExecutor) updateAntigravityCreditsBalanceForTask(ctx context return } - userAgent := resolveUserAgent(auth) + userAgent := resolveLoadCodeAssistUserAgent(auth) loadReqBody, errMarshal := json.Marshal(map[string]any{ "metadata": map[string]string{ "ideType": "ANTIGRAVITY", @@ -534,7 +540,6 @@ func (e *AntigravityExecutor) updateAntigravityCreditsBalanceForTask(ctx context return } httpReq.Header.Set("Authorization", "Bearer "+token) - httpReq.Header.Set("Accept", "*/*") httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("User-Agent", userAgent) diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 3c7d552a..e1eba611 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -75,6 +75,13 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au return resp, errValidate } req.Payload = originalPayload + // Observe a translation refusal before the token refresh or any upstream call. + modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) + translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, ModelInfo: modelInfo} + originalTranslated, translated, errTranslate := helps.TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq, originalPayload, req.Payload) + if errTranslate != nil { + return resp, errTranslate + } token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) if errToken != nil { return resp, errToken @@ -83,18 +90,18 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au auth = updatedAuth reporter.UpdateAccessTokenFingerprint(auth) } - modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) - translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, ModelInfo: modelInfo} - originalTranslated, translated := helps.TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq, originalPayload, req.Payload) translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts) + ctx = helps.WithPayloadFinalizer(ctx, func(body []byte) []byte { + out := finalize(body) + reporter.SetTranslatedRequestMetadata(out, to.String()) + return out + }) translated = e.obfuscateSensitiveWords(translated) translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) reporter.SetTranslatedReasoningEffort(translated, to.String()) @@ -285,6 +292,13 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * return resp, errValidate } req.Payload = originalPayload + // Observe a translation refusal before the token refresh or any upstream call. + modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) + translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, Stream: true, ModelInfo: modelInfo} + originalTranslated, translated, errTranslate := helps.TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq, originalPayload, req.Payload) + if errTranslate != nil { + return resp, errTranslate + } token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) if errToken != nil { return resp, errToken @@ -293,18 +307,18 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * auth = updatedAuth reporter.UpdateAccessTokenFingerprint(auth) } - modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) - translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, Stream: true, ModelInfo: modelInfo} - originalTranslated, translated := helps.TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq, originalPayload, req.Payload) translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts) + ctx = helps.WithPayloadFinalizer(ctx, func(body []byte) []byte { + out := finalize(body) + reporter.SetTranslatedRequestMetadata(out, to.String()) + return out + }) translated = e.obfuscateSensitiveWords(translated) translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) reporter.SetTranslatedReasoningEffort(translated, to.String()) diff --git a/internal/runtime/executor/antigravity_executor_request.go b/internal/runtime/executor/antigravity_executor_request.go index 574b16d5..bdb56aaa 100644 --- a/internal/runtime/executor/antigravity_executor_request.go +++ b/internal/runtime/executor/antigravity_executor_request.go @@ -57,6 +57,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau return nil, errProject } payload = geminiToAntigravity(modelName, payload, projectID, derivedSessionIDs...) + payload, promptStructuredOutput := helps.PrepareAntigravityClaudeStructuredOutput(modelName, payload) // Cap maxOutputTokens to model's max_completion_tokens from registry if maxOut := gjson.GetBytes(payload, "request.generationConfig.maxOutputTokens"); maxOut.Exists() && maxOut.Type == gjson.Number { @@ -67,34 +68,41 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau } } - useAntigravitySchema := strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro") || strings.Contains(modelName, "gemini-3.1-pro") + isClaudeModel := strings.Contains(modelName, "claude") + useAntigravitySchema := isClaudeModel || strings.Contains(modelName, "gemini-3-pro") || strings.Contains(modelName, "gemini-3.1-pro") var ( bodyReader io.Reader payloadLog []byte ) if antigravityRequestNeedsSchemaSanitization(payload) { - payloadStr := sanitizeAntigravityRequestSchemas(string(payload), useAntigravitySchema) + payloadStr := sanitizeAntigravityRequestSchemasForModel(string(payload), useAntigravitySchema, isClaudeModel) - if strings.Contains(modelName, "claude") { - updated, _ := sjson.SetBytes([]byte(payloadStr), "request.toolConfig.functionCallingConfig.mode", "VALIDATED") - payloadStr = string(updated) + if isClaudeModel { + if !promptStructuredOutput { + updated, _ := sjson.SetBytes([]byte(payloadStr), "request.toolConfig.functionCallingConfig.mode", "VALIDATED") + payloadStr = string(updated) + } } else { payloadStr, _ = sjson.Delete(payloadStr, "request.generationConfig.maxOutputTokens") } payloadStrBytes := applyAntigravityNativeSignatureReplayIfNeeded(modelName, []byte(payloadStr)) + payloadStrBytes = helps.FinalizePayload(ctx, payloadStrBytes) bodyReader = bytes.NewReader(payloadStrBytes) if e.cfg != nil && e.cfg.RequestLog { payloadLog = append([]byte(nil), payloadStrBytes...) } } else { - if strings.Contains(modelName, "claude") { - payload, _ = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") + if isClaudeModel { + if !promptStructuredOutput { + payload, _ = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") + } } else { payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.maxOutputTokens") } payload = applyAntigravityNativeSignatureReplayIfNeeded(modelName, payload) + payload = helps.FinalizePayload(ctx, payload) bodyReader = bytes.NewReader(payload) if e.cfg != nil && e.cfg.RequestLog { payloadLog = append([]byte(nil), payload...) @@ -162,8 +170,19 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau // data keys inside functionCall arguments replayed from conversation history. Running it over the // whole document silently mutated that history, so tools lost required argument fields and the // model imitated the corrupted examples on later turns. +// sanitizeAntigravityRequestSchemas cleans with union-preserving (Gemini) tool semantics. +// Claude tool unions are flattened only when the caller supplies the model flag via +// sanitizeAntigravityRequestSchemasForModel; the placeholder group alone must not decide it, +// because gemini-3-pro models share that group and accept preserved unions. func sanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema bool) string { - payloadStr = sanitizeAntigravityToolSchemas(payloadStr, useAntigravitySchema) + return sanitizeAntigravityRequestSchemasForModel(payloadStr, useAntigravitySchema, false) +} + +// sanitizeAntigravityRequestSchemasForModel applies sanitizeAntigravityRequestSchemas with an +// explicit Antigravity Claude flag. Antigravity Claude tool schemas additionally flatten +// multi-branch anyOf/oneOf unions, which the Claude-side upstream rejects (Issue 3662). +func sanitizeAntigravityRequestSchemasForModel(payloadStr string, useAntigravitySchema, isClaudeModel bool) string { + payloadStr = sanitizeAntigravityToolSchemasForModel(payloadStr, useAntigravitySchema, isClaudeModel) return sanitizeAntigravityGenerationSchemas(payloadStr) } @@ -172,13 +191,17 @@ func sanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema b // once. This preserves rewrite order and bytes without copying the full request // for every declaration schema. func sanitizeAntigravityToolSchemas(payloadStr string, useAntigravitySchema bool) string { + return sanitizeAntigravityToolSchemasForModel(payloadStr, useAntigravitySchema, false) +} + +func sanitizeAntigravityToolSchemasForModel(payloadStr string, useAntigravitySchema, isClaudeModel bool) string { tools := gjson.Get(payloadStr, "request.tools") if !tools.IsArray() { return payloadStr } toolDocument := `{"request":{"tools":` + tools.Raw + `}}` - toolDocument = sanitizeAntigravityToolSchemaDocument(toolDocument, useAntigravitySchema) + toolDocument = sanitizeAntigravityToolSchemaDocumentForModel(toolDocument, useAntigravitySchema, isClaudeModel) cleanedTools := gjson.Get(toolDocument, "request.tools") if !cleanedTools.IsArray() || cleanedTools.Raw == tools.Raw { return payloadStr @@ -192,30 +215,194 @@ func sanitizeAntigravityToolSchemas(payloadStr string, useAntigravitySchema bool } func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySchema bool) string { - for _, base := range antigravityFunctionDeclarationPaths(payloadStr) { - oldPath := base + ".parametersJsonSchema" - if !gjson.Get(payloadStr, oldPath).Exists() { - continue + return sanitizeAntigravityToolSchemaDocumentForModel(payloadStr, useAntigravitySchema, false) +} + +// antigravityDeclarationContainers lists both spellings of the declarations +// array a tool may carry. +var antigravityDeclarationContainers = []string{"functionDeclarations", "function_declarations"} + +// sanitizeAntigravityToolSchemaDocumentForModel renames and cleans every +// declaration schema in the tools-only document. Each declaration is edited +// on its own raw bytes and the results are spliced back into the tools array +// in a single pass, so the cost stays linear in the document size instead of +// scaling with declarations x document size (Issue 6714). The emitted bytes +// match the previous whole-document edits: changed elements are spliced at +// their original offsets, so untouched bytes (including whitespace around +// array elements) survive verbatim, and changed nodes go through the same +// sjson edits, scoped to one declaration. +func sanitizeAntigravityToolSchemaDocumentForModel(payloadStr string, useAntigravitySchema, isClaudeModel bool) string { + toolSchemaCleaner := func(schema string, isParameterSchema bool) string { + // Only declaration parameter schemas become Claude custom.input_schema upstream, + // so only they need Claude union flattening. Function return schemas keep the + // union-preserving cleaning like every other response-side schema. + if isClaudeModel && isParameterSchema { + return util.CleanJSONSchemaForAntigravityClaudeTool(schema, useAntigravitySchema) } - renamed, errRename := util.RenameKey(payloadStr, oldPath, base+".parameters") - if errRename != nil { - log.Debugf("antigravity: failed to rename %s: %v", oldPath, errRename) - continue + return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema) + } + cleanNestedToolSchema := func(schemaRaw string, isParameterSchema bool) string { + return cleanNestedSchema(func(schema string) string { + return toolSchemaCleaner(schema, isParameterSchema) + }, schemaRaw) + } + + tools := gjson.Parse(payloadStr).Get("request.tools") + if !tools.IsArray() { + return payloadStr + } + elements := make([]gjson.Result, 0) + edits := make([]antigravityElementEdit, 0) + tools.ForEach(func(_, tool gjson.Result) bool { + elements = append(elements, tool) + if !tool.IsObject() { + return true } - payloadStr = renamed + toolRaw := tool.Raw + for _, declKey := range antigravityDeclarationContainers { + decls := tool.Get(declKey) + if !decls.IsArray() { + continue + } + cleanedDecls, declsChanged := sanitizeAntigravityDeclarations(decls, cleanNestedToolSchema) + if !declsChanged { + continue + } + updated, errSet := sjson.SetRawBytes([]byte(toolRaw), declKey, []byte(cleanedDecls)) + if errSet != nil { + log.Debugf("antigravity: failed to write cleaned %s: %v", declKey, errSet) + continue + } + toolRaw = string(updated) + } + if toolRaw != tool.Raw { + edits = append(edits, antigravityElementEdit{offset: tool.Index - tools.Index, raw: tool.Raw, replacement: toolRaw}) + } + return true + }) + if len(edits) == 0 { + return payloadStr + } + updated, errSet := sjson.SetRawBytes([]byte(payloadStr), "request.tools", []byte(applyAntigravityElementEdits(tools.Raw, tools.Index, elements, edits))) + if errSet != nil { + log.Debugf("antigravity: failed to write cleaned request.tools: %v", errSet) + return payloadStr } + return string(updated) +} - toolSchemaCleaner := func(schema string) string { - return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema) +// antigravityElementEdit replaces one array element (raw, at offset within +// the array's raw bytes) with replacement. +type antigravityElementEdit struct { + offset int + raw string + replacement string +} + +// applyAntigravityElementEdits splices element replacements into an array's +// raw bytes in offset order, keeping every other byte (commas, whitespace, +// unchanged elements) verbatim. Edits whose offsets do not line up with the +// array bytes fall back to joining the elements with commas: still valid +// and semantically identical, just without the original separators. gjson +// reports element offsets into the parsed document, so offsets here are +// relative to the array start. +func applyAntigravityElementEdits(arrayRaw string, arrayIndex int, elements []gjson.Result, edits []antigravityElementEdit) string { + var rebuilt strings.Builder + rebuilt.Grow(len(arrayRaw)) + cursor := 0 + for _, edit := range edits { + if edit.offset < cursor || edit.offset+len(edit.raw) > len(arrayRaw) || arrayRaw[edit.offset:edit.offset+len(edit.raw)] != edit.raw { + return joinAntigravityElements(arrayIndex, elements, edits) + } + rebuilt.WriteString(arrayRaw[cursor:edit.offset]) + rebuilt.WriteString(edit.replacement) + cursor = edit.offset + len(edit.raw) } - cleanNestedToolSchema := func(schemaRaw string) string { - return cleanNestedSchema(toolSchemaCleaner, schemaRaw) + rebuilt.WriteString(arrayRaw[cursor:]) + return rebuilt.String() +} + +// joinAntigravityElements rebuilds an array by joining element bytes with +// commas, substituting replacements where recorded. It is the fallback for +// applyAntigravityElementEdits when splice offsets cannot be trusted. +func joinAntigravityElements(arrayIndex int, elements []gjson.Result, edits []antigravityElementEdit) string { + replacements := make(map[int]string, len(edits)) + for _, edit := range edits { + replacements[edit.offset] = edit.replacement + } + var rebuilt strings.Builder + rebuilt.WriteByte('[') + for i, element := range elements { + if i > 0 { + rebuilt.WriteByte(',') + } + if replacement, ok := replacements[element.Index-arrayIndex]; ok { + rebuilt.WriteString(replacement) + } else { + rebuilt.WriteString(element.Raw) + } } - return cleanAntigravitySchemasAtPaths( - payloadStr, - antigravityDeclarationSchemaPaths(payloadStr), - cleanNestedToolSchema, - ) + rebuilt.WriteByte(']') + return rebuilt.String() +} + +// sanitizeAntigravityDeclarations rebuilds one declarations array, applying +// the rename and schema cleaning to each declaration in isolation. It +// reports whether any declaration actually changed so callers can keep the +// original bytes when the array is already clean. +func sanitizeAntigravityDeclarations(decls gjson.Result, clean func(schemaRaw string, isParameterSchema bool) string) (string, bool) { + elements := make([]gjson.Result, 0) + edits := make([]antigravityElementEdit, 0) + decls.ForEach(func(_, decl gjson.Result) bool { + elements = append(elements, decl) + if !decl.IsObject() { + return true + } + cleanedDecl := sanitizeAntigravityDeclaration(decl, clean) + if cleanedDecl != decl.Raw { + edits = append(edits, antigravityElementEdit{offset: decl.Index - decls.Index, raw: decl.Raw, replacement: cleanedDecl}) + } + return true + }) + if len(edits) == 0 { + return "", false + } + return applyAntigravityElementEdits(decls.Raw, decls.Index, elements, edits), true +} + +// sanitizeAntigravityDeclaration applies the declaration-level edits that +// used to run against the whole tools document: parametersJsonSchema is +// renamed onto parameters first, then every object schema is cleaned in +// place. Failed edits keep the declaration as-is and are only logged. +func sanitizeAntigravityDeclaration(decl gjson.Result, clean func(schemaRaw string, isParameterSchema bool) string) string { + declRaw := decl.Raw + if decl.Get("parametersJsonSchema").Exists() { + renamed, errRename := util.RenameKey(declRaw, "parametersJsonSchema", "parameters") + if errRename != nil { + log.Debugf("antigravity: failed to rename parametersJsonSchema: %v", errRename) + } else { + declRaw = renamed + decl = gjson.Parse(declRaw) + } + } + for _, key := range antigravityDeclarationSchemaKeys { + schema := decl.Get(key) + if !schema.IsObject() { + continue + } + cleanedSchema := clean(schema.Raw, isParameterSchemaPath(key)) + if cleanedSchema == schema.Raw { + continue + } + updated, errSet := sjson.SetRawBytes([]byte(declRaw), key, []byte(cleanedSchema)) + if errSet != nil { + log.Debugf("antigravity: failed to write cleaned schema at %s: %v", key, errSet) + continue + } + declRaw = string(updated) + decl = gjson.Parse(declRaw) + } + return declRaw } // sanitizeAntigravityGenerationSchemas batches every schema edit within one @@ -256,6 +443,16 @@ func sanitizeAntigravityGenerationSchemas(payloadStr string) string { return payloadStr } +// isParameterSchemaPath reports whether a declaration schema path holds tool input +// parameters rather than a function return schema. +func isParameterSchemaPath(schemaPath string) bool { + key := schemaPath + if idx := strings.LastIndex(schemaPath, "."); idx >= 0 { + key = schemaPath[idx+1:] + } + return strings.HasPrefix(key, "parameters") +} + func cleanAntigravitySchemasAtPaths(payloadStr string, schemaPaths []string, clean func(string) string) string { for _, schemaPath := range schemaPaths { schema := gjson.Get(payloadStr, schemaPath) @@ -394,10 +591,7 @@ func resolveAntigravityRequestBaseURL(auth *cliproxyauth.Auth) string { } func antigravityLoadCodeAssistBaseURL(auth *cliproxyauth.Auth) string { - if base := resolveCustomAntigravityBaseURL(auth); base != "" { - return base - } - return antigravityBaseURLProd + return resolveAntigravityRequestBaseURL(auth) } func resolveHost(base string) string { @@ -457,7 +651,7 @@ func resolveCustomAntigravityBaseURL(auth *cliproxyauth.Auth) string { } func geminiToAntigravity(modelName string, payload []byte, projectID string, derivedSessionIDs ...string) []byte { - template := payload + template := bytes.Clone(payload) template = helps.SetStringIfDifferent(template, "model", modelName) template = helps.SetStringIfDifferent(template, "userAgent", "antigravity") diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index 0d2d6243..bba3a961 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -8,12 +8,14 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" @@ -73,6 +75,13 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya return nil, errValidate } req.Payload = originalPayload + // Observe a translation refusal before the token refresh or any upstream call. + modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) + translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, Stream: true, ModelInfo: modelInfo} + originalTranslated, translated, errTranslate := helps.TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq, originalPayload, req.Payload) + if errTranslate != nil { + return nil, errTranslate + } token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) if errToken != nil { return nil, errToken @@ -82,18 +91,17 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya reporter.UpdateAccessTokenFingerprint(auth) } - modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) - translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, Stream: true, ModelInfo: modelInfo} - originalTranslated, translated := helps.TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq, originalPayload, req.Payload) - translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts) + ctx = helps.WithPayloadFinalizer(ctx, func(body []byte) []byte { + out := finalize(body) + reporter.SetTranslatedRequestMetadata(out, to.String()) + return out + }) translated = e.obfuscateSensitiveWords(translated) translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) translated, _ = sjson.DeleteBytes(translated, "request.stream") @@ -194,8 +202,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya } replayAccumulator := newAntigravityReasoningReplayAccumulator(replayScope, requestPayload) out := make(chan cliproxyexecutor.StreamChunk) + stopDeliverySupport := usage.SupportStreamDelivery(ctx) go func(resp *http.Response) { - defer close(out) defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("antigravity executor: close response line error: %v", errClose) @@ -205,8 +213,52 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya scanner.Buffer(nil, streamScannerBuffer) claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) var streamUsage helps.StreamUsageBuffer - defer streamUsage.Publish(ctx, reporter) + var streamErr error + var pendingJSON []byte + var replayCommitted bool + var terminalDelivered bool var param any + commitReplay := func() { + if !replayCommitted && replayAccumulator != nil && replayAccumulator.terminal && helps.ApplyPatchTranslationError(param) == nil { + replayAccumulator.Commit(ctx) + replayCommitted = true + } + } + defer func() { + defer stopDeliverySupport() + if helps.ApplyPatchTranslationError(param) != nil { + streamErr = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + stopDeliverySupport() + } + // Replay is settled before forwarding completion; accounting still waits + // for the HTTP consumer, which may need EOF to acknowledge delivery. + if terminalDelivered { + commitReplay() + } + close(out) + if streamErr == nil { + streamErr = ctx.Err() + } + if deliveryErr, tracked := usage.WaitStreamDelivery(ctx); tracked { + if streamErr == nil || errors.Is(streamErr, context.Canceled) { + streamErr = deliveryErr + } + } + if streamErr != nil { + if terminalDelivered && errors.Is(streamErr, context.Canceled) { + streamUsage.Publish(ctx, reporter) + reporter.EnsurePublished(ctx) + return + } + if !streamUsage.PublishFailure(ctx, reporter, streamErr) { + reporter.PublishFailure(ctx, streamErr) + } + return + } + + streamUsage.Publish(ctx, reporter) + reporter.EnsurePublished(ctx) + }() helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, ¶m) for scanner.Scan() { line := scanner.Bytes() @@ -215,23 +267,100 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya replayAccumulator.ObserveSSELine(line) } + // Capture accounting before the client-facing filter renames usage. + streamUsage.Observe(helps.ParseAntigravityStreamUsage(line)) + // Filter usage metadata for all models // Only retain usage statistics in the terminal chunk line = helps.FilterSSEUsageMetadata(line) payload := helps.JSONPayload(line) + if len(pendingJSON) > 0 { + trimmedLine := bytes.TrimSpace(line) + if bytes.HasPrefix(trimmedLine, []byte("data:")) { + trimmedLine = bytes.TrimSpace(trimmedLine[len("data:"):]) + } + if len(trimmedLine) > 0 { + pendingJSON = append(pendingJSON, '\n') + pendingJSON = append(pendingJSON, trimmedLine...) + } + if !gjson.ValidBytes(pendingJSON) { + continue + } + payload = pendingJSON + pendingJSON = nil + } else if payload != nil && !gjson.ValidBytes(payload) { + pendingJSON = append([]byte(nil), payload...) + continue + } if payload == nil { continue } - reporter.ObserveResponseModel(payload) - - if detail, ok := helps.ParseAntigravityStreamUsage(payload); ok { - streamUsage.Observe(detail, true) + if errorResult := gjson.GetBytes(payload, "error"); errorResult.Exists() { + statusCode := int(errorResult.Get("code").Int()) + if statusCode < http.StatusBadRequest || statusCode > 599 { + statusCode = http.StatusBadGateway + } + streamErr = newAntigravityStatusErr(statusCode, payload) + stopDeliverySupport() + helps.RecordAPIResponseError(ctx, e.cfg, streamErr) + select { + case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: + case <-ctx.Done(): + } + return } + reporter.ObserveResponseModel(payload) payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, payload) chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, bytes.Clone(payload), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + var isTerminalChunk bool + if finishReason := gjson.GetBytes(payload, "candidates.0.finishReason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" { + isTerminalChunk = true + } else if finishReason := gjson.GetBytes(payload, "response.candidates.0.finishReason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" { + isTerminalChunk = true + } else if replayAccumulator != nil && replayAccumulator.terminal { + isTerminalChunk = true + } + if !isTerminalChunk { + for _, chunk := range chunks { + for _, cLine := range bytes.Split(chunk, []byte("\n")) { + trimmed := bytes.TrimSpace(cLine) + if bytes.Equal(trimmed, []byte("data: [DONE]")) || bytes.Equal(trimmed, []byte("[DONE]")) { + isTerminalChunk = true + break + } + cPayload := helps.JSONPayload(cLine) + if len(cPayload) == 0 { + continue + } + cType := gjson.GetBytes(cPayload, "type").String() + if cType == "response.completed" || cType == "message_stop" { + isTerminalChunk = true + break + } + if finishReason := gjson.GetBytes(cPayload, "choices.0.finish_reason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" { + isTerminalChunk = true + break + } + } + if isTerminalChunk { + break + } + } + } + if replayAccumulator != nil && replayAccumulator.terminal && !replayCommitted && responseFormat == sdktranslator.FormatOpenAIResponse { + // Wait for the translated completion, not the first finishReason: + // split usage/signature frames may still extend the replay ledger. + for _, chunk := range chunks { + for _, line := range bytes.Split(chunk, []byte("\n")) { + if gjson.GetBytes(helps.JSONPayload(line), "type").String() == "response.completed" { + commitReplay() + } + } + } + } for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -239,6 +368,9 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya return } } + if isTerminalChunk { + terminalDelivered = true + } if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } @@ -247,8 +379,17 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya return } if errScan := scanner.Err(); errScan != nil { + streamErr = errScan + if errors.Is(errScan, context.Canceled) && ctx.Err() != nil { + if terminalDelivered { + return + } + if deliveryErr, tracked := usage.WaitStreamDelivery(ctx); tracked && deliveryErr == nil { + return + } + } + stopDeliverySupport() helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): @@ -256,9 +397,14 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya } else if ctx.Err() == nil { // Only a clean end of stream may produce a synthetic terminal event. // Translating [DONE] after a read error would report a truncated - // stream as a successful completion. + // stream as a successful completion. A clean EOF is a normal end + // even without finishReason: real Antigravity tool-call streams + // end that way (see antigravityNoFinishReasonSSE). tail := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, []byte("[DONE]"), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + // Commit upstream output before EOF-generated completion reaches interceptors. + // Commit itself rejects partial streams without an upstream terminal. + commitReplay() for i := range tail { select { case out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}: @@ -266,13 +412,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya return } } - if replayAccumulator != nil { - replayAccumulator.Commit(ctx) - } - // The reporter keeps only the first outcome. Publish the buffered - // usage before EnsurePublished, which otherwise records an empty detail. - streamUsage.Publish(ctx, reporter) - reporter.EnsurePublished(ctx) + terminalDelivered = true } }(httpResp) return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index 9085fea5..9ed35aa6 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -35,6 +35,18 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut return cliproxyexecutor.Response{}, errValidate } req.Payload = originalPayloadSource + + // Prepare payload once (doesn't depend on baseURL). A translation refusal is + // observed before the token refresh or any upstream call. + modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) + translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, Body: req.Payload, ModelInfo: modelInfo} + translatedReq := helps.TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq) + if translatedReq.Err != nil { + return cliproxyexecutor.Response{}, translatedReq.Err + } + payload := translatedReq.Body + originalTranslatedForPayload := append([]byte(nil), payload...) + token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) if errToken != nil { return cliproxyexecutor.Response{}, errToken @@ -47,16 +59,12 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} } - // Prepare payload once (doesn't depend on baseURL) - modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) - translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, Body: req.Payload, ModelInfo: modelInfo} - payload := helps.TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq).Body - payload, err := helps.ApplyRequestThinking(payload, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } payload = e.obfuscateSensitiveWords(payload) + payload, _ = helps.PrepareAntigravityClaudeStructuredOutput(baseModel, payload) payload = sanitizeAntigravityGeminiRequestSignatures(baseModel, payload) preparedPayload, _, errReplay := prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, payload) if errReplay != nil { @@ -89,6 +97,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut requestURL.WriteString(url.QueryEscape(opts.Alt)) } + payload = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "request", originalTranslatedForPayload, req, opts)(payload) httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload)) if errReq != nil { return cliproxyexecutor.Response{}, errReq diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index 2bf053e8..ad8d3a63 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -20,6 +20,7 @@ import ( internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -71,6 +72,7 @@ type antigravityReasoningReplayScope struct { modelName string sessionKey string cacheSnapshot internalcache.AntigravityReasoningReplaySnapshot + openAIChat bool } func (s antigravityReasoningReplayScope) valid() bool { @@ -100,17 +102,30 @@ func antigravityReasoningReplayScopeFromRequest(ctx context.Context, modelName s // Prefer an explicit downstream session over a provider sessionId synthesized // from request text. This keeps identical prompts in separate client sessions // from sharing an opaque Gemini reasoning chain. + scope := antigravityReasoningReplayScope{modelName: modelName} if sessionKey := antigravityReasoningReplayClientSessionKey(ctx, req, opts); sessionKey != "" { - return antigravityReasoningReplayScope{modelName: modelName, sessionKey: sessionKey} + scope.sessionKey = sessionKey + } else { + scope = antigravityReasoningReplayScopeFromPayload(modelName, payload) + if !scope.valid() { + scope = antigravityReasoningReplayScopeFromPayload(modelName, req.Payload) + } + } + scope.openAIChat = opts.SourceFormat.String() == "openai" + // Only trusted execution metadata can namespace the ledger. Never read a + // caller identity from request JSON or downstream credential headers here. + callerScope := metadataString(opts.Metadata, cliproxyexecutor.CallerScopeMetadataKey) + if callerScope == "" { + callerScope = metadataString(req.Metadata, cliproxyexecutor.CallerScopeMetadataKey) } - if scope := antigravityReasoningReplayScopeFromPayload(modelName, payload); scope.valid() { - return scope + if callerScope == "" { + callerScope = session.CallerScope(helps.APIKeyFromContext(ctx)) } - if scope := antigravityReasoningReplayScopeFromPayload(modelName, req.Payload); scope.valid() { - return scope + if scope.valid() && callerScope != "" { + sum := sha256.Sum256([]byte(callerScope)) + scope.sessionKey = "caller:" + hex.EncodeToString(sum[:]) + ":" + scope.sessionKey } - _ = ctx - return antigravityReasoningReplayScope{} + return scope } func antigravitySessionHeaderValue(headers http.Header, names ...string) string { @@ -167,6 +182,11 @@ func antigravityReasoningReplayClientSessionKey(ctx context.Context, req cliprox return "prompt-cache:" + value } } + // Reuse ingress extraction for explicit session aliases before falling back + // to inferred roots. Keep the established session/execution priorities above. + if info, ok := session.ExtractSessionInfo(opts.Headers, nil, nil); ok && info.SessionID != "" { + return "client:" + info.SessionID + } if value := helps.DerivedSessionID(opts.Metadata, req.Metadata); value != "" { return "derived:" + value } @@ -350,17 +370,19 @@ func applyAntigravityReasoningReplayItems(payload []byte, items [][]byte, toolSc updated := payload changed := false tracker := newAntigravityReplayContextHashLogTracker() - defer func() { - if tracker != nil && tracker.rejections > 1 { - log.Debugf("antigravity replay: suppressed %d repeated context-hash rejections across %d parts in same request", - tracker.rejections-1, len(tracker.loggedKeys)) - } - }() + defer tracker.log() index := newAntigravityReplayRequestIndexWithTracker(payload, tracker) for len(items) > 0 { + tracker.setPhase(antigravityReplayPhaseBatch) batch := newAntigravityReplayBatch(index) handled := 0 - for handled < len(items) && batch.apply(items[handled], toolSchemas) { + contextHashStopped := false + for handled < len(items) { + rejectionsBefore := tracker.contextHashRejectionCount() + if !batch.apply(items[handled], toolSchemas) { + contextHashStopped = tracker.contextHashRejectionCount() > rejectionsBefore + break + } handled++ } if handled > 0 { @@ -369,6 +391,7 @@ func applyAntigravityReasoningReplayItems(payload []byte, items [][]byte, toolSc // A malformed or non-addressable part offset makes splicing unsafe. // Nothing has been committed yet, so retain the exact legacy behavior // for all remaining items. + tracker.setPhase(antigravityReplayPhaseSequential) next, sequentialChanged := applyAntigravityReasoningReplayItemsSequential(index, updated, items, toolSchemas) return next, changed || sequentialChanged } @@ -379,16 +402,20 @@ func applyAntigravityReasoningReplayItems(payload []byte, items [][]byte, toolSc break } index = newAntigravityReplayRequestIndexWithTracker(updated, tracker) - // Retry the first unhandled item against the freshly flushed payload. - // It may only have rejected the batch because a prior stable-ID restore - // made the old context index stale. - continue + // Retry only when this batch restored a stable ID. That changes the + // context fingerprint, so the rejection may belong to the stale index. + // A context-hash mismatch on an unchanged identity cannot be repaired + // by rebuilding the same payload. + if !(contextHashStopped && !batch.identityChanged) { + continue + } } // Apply one structural or context-dependent item with the original // sequential implementation, then start another safe batch. A rare // fallback therefore cannot make every preceding signature rewrite the // entire request again. + tracker.setPhase(antigravityReplayPhaseSequential) next, itemChanged := applyAntigravityReasoningReplayItemsSequential(index, updated, items[:1], toolSchemas) updated = next changed = itemChanged || changed @@ -418,7 +445,7 @@ func applyAntigravityReasoningReplayItemsSequential(index *antigravityReplayRequ // mutation so later items observe exactly the same payload as before. // The final item has no successor, so its rebuild would never be read. if itemIndex+1 < len(items) { - index = newAntigravityReplayRequestIndex(updated) + index = newAntigravityReplayRequestIndexWithTracker(updated, index.logTracker) } } return updated, changed @@ -989,14 +1016,12 @@ func (i *antigravityReplayRequestIndex) functionCallPartLocationForReplayWithSch // identical. Only the surrounding context drifted, which invalidates the // cached signature but not the tool identity. key := antigravityReplayPartKey{contentIndex: location.contentIndex, partIndex: location.partIndex} - i.logTracker.rejections++ - if !i.logTracker.loggedKeys[key] { - if len(i.logTracker.loggedKeys) == 0 { - log.Debugf("antigravity replay: exact tool ID match for %q at contents[%d].parts[%d] rejected by context hash (opaque_id=%t)", - name, location.contentIndex, location.partIndex, util.IsGeminiClaudeToolUseID(candidateID)) - } - i.logTracker.loggedKeys[key] = true - } + i.logTracker.recordRejection(antigravityReplayContextHashSample{ + name: name, + contentIndex: location.contentIndex, + partIndex: location.partIndex, + opaqueID: util.IsGeminiClaudeToolUseID(candidateID), + }, key) return antigravityReplayIndexedPart{}, false } @@ -1322,26 +1347,121 @@ type antigravityReplayIndexedContent struct { // alias the payload bytes and memoizes context fingerprints lazily, so it must // be discarded and rebuilt as soon as the payload changes, and it must never be // shared across goroutines. +type antigravityReplayPhase int + +const ( + antigravityReplayPhaseBatch antigravityReplayPhase = iota + antigravityReplayPhaseSequential +) + +type antigravityReplayContextHashSample struct { + name string + contentIndex int + partIndex int + opaqueID bool +} + +type antigravityReplayContextHashGroupKey struct { + toolName string + opaqueID bool +} + +type antigravityReplayContextHashGroup struct { + key antigravityReplayContextHashGroupKey + batchRejections int + sequentialRejections int + loggedKeys map[antigravityReplayPartKey]bool + sampleContentIndex int + samplePartIndex int +} + type antigravityReplayContextHashLogTracker struct { - rejections int - loggedKeys map[antigravityReplayPartKey]bool + phase antigravityReplayPhase + totalContextHashRejections int + groups map[antigravityReplayContextHashGroupKey]*antigravityReplayContextHashGroup + order []antigravityReplayContextHashGroupKey } func newAntigravityReplayContextHashLogTracker() *antigravityReplayContextHashLogTracker { return &antigravityReplayContextHashLogTracker{ - loggedKeys: make(map[antigravityReplayPartKey]bool), + groups: make(map[antigravityReplayContextHashGroupKey]*antigravityReplayContextHashGroup), + } +} + +func (t *antigravityReplayContextHashLogTracker) setPhase(phase antigravityReplayPhase) { + if t != nil { + t.phase = phase + } +} + +func (t *antigravityReplayContextHashLogTracker) contextHashRejectionCount() int { + if t == nil { + return 0 + } + return t.totalContextHashRejections +} + +func (t *antigravityReplayContextHashLogTracker) recordRejection(sample antigravityReplayContextHashSample, key antigravityReplayPartKey) { + if t == nil { + return + } + t.totalContextHashRejections++ + groupKey := antigravityReplayContextHashGroupKey{ + toolName: sample.name, + opaqueID: sample.opaqueID, + } + group, exists := t.groups[groupKey] + if !exists { + group = &antigravityReplayContextHashGroup{ + key: groupKey, + loggedKeys: make(map[antigravityReplayPartKey]bool), + sampleContentIndex: sample.contentIndex, + samplePartIndex: sample.partIndex, + } + t.groups[groupKey] = group + t.order = append(t.order, groupKey) + } + if t.phase == antigravityReplayPhaseSequential { + group.sequentialRejections++ + } else { + group.batchRejections++ } + group.loggedKeys[key] = true } +func (t *antigravityReplayContextHashLogTracker) log() { + if t == nil || len(t.groups) == 0 { + return + } + for _, groupKey := range t.order { + group := t.groups[groupKey] + if group == nil || (group.batchRejections == 0 && group.sequentialRejections == 0) { + continue + } + partsCount := len(group.loggedKeys) + partsNoun := "parts" + if partsCount == 1 { + partsNoun = "part" + } + log.Debugf("antigravity replay: context hash rejected %d %s (first=%q at contents[%d].parts[%d], opaque_id=%t, batch=%d, seq=%d)", + partsCount, partsNoun, group.key.toolName, group.sampleContentIndex, group.samplePartIndex, group.key.opaqueID, + group.batchRejections, group.sequentialRejections) + } +} + +const antigravityOpenAIReplayContextPrefix = "openai-chat-v1:" + type antigravityReplayRequestIndex struct { - validContents bool - contents []antigravityReplayIndexedContent - functionCallsByID map[string]antigravityReplayIndexedPart - functionCallCountsByID map[string]int - functionResponseContentByID map[string]int - functionResponsePartsByID map[string][]antigravityReplayPartKey - contextFingerprints *antigravityReplayContextFingerprints - logTracker *antigravityReplayContextHashLogTracker + payload []byte + openAIChatContextFingerprints *antigravityReplayContextFingerprints + validContents bool + contents []antigravityReplayIndexedContent + functionCallsByID map[string]antigravityReplayIndexedPart + functionCallCountsByID map[string]int + functionResponseContentByID map[string]int + functionResponsePartsByID map[string][]antigravityReplayPartKey + contextFingerprints *antigravityReplayContextFingerprints + logTracker *antigravityReplayContextHashLogTracker } func newAntigravityReplayRequestIndex(payload []byte) *antigravityReplayRequestIndex { @@ -1353,6 +1473,7 @@ func newAntigravityReplayRequestIndexWithTracker(payload []byte, tracker *antigr tracker = newAntigravityReplayContextHashLogTracker() } index := &antigravityReplayRequestIndex{ + payload: payload, functionCallsByID: make(map[string]antigravityReplayIndexedPart), functionCallCountsByID: make(map[string]int), functionResponseContentByID: make(map[string]int), @@ -1399,7 +1520,7 @@ func newAntigravityReplayRequestIndexWithTracker(payload []byte, tracker *antigr } } } - index.contextFingerprints = newAntigravityReplayContextFingerprints(payload, index.contents, index.validContents) + index.contextFingerprints = newAntigravityReplayContextFingerprints(payload, index.contents, index.validContents, true) return index } @@ -1426,8 +1547,21 @@ func (i *antigravityReplayRequestIndex) contextFingerprint(beforeContentIndex in return i.contextFingerprints.at(beforeContentIndex) } +// openAIChatContextFingerprint binds historical calls to system instructions and +// preceding contents, not the current turn's tool declarations or tool choice. +// The version tag leaves legacy and non-OpenAI ledger matching unchanged. +func (i *antigravityReplayRequestIndex) openAIChatContextFingerprint(contentIndex int) string { + if i.openAIChatContextFingerprints == nil { + i.openAIChatContextFingerprints = newAntigravityReplayContextFingerprints(i.payload, i.contents, i.validContents, false) + } + return antigravityOpenAIReplayContextPrefix + i.openAIChatContextFingerprints.at(contentIndex) +} + func (i *antigravityReplayRequestIndex) contextMatches(itemResult gjson.Result, contentIndex int) bool { expected := strings.TrimSpace(itemResult.Get("contextHash").String()) + if strings.HasPrefix(expected, antigravityOpenAIReplayContextPrefix) { + return expected == i.openAIChatContextFingerprint(contentIndex) + } return expected == "" || expected == i.contextFingerprint(contentIndex) } @@ -1468,6 +1602,7 @@ func newAntigravityReplayContextFingerprints( payload []byte, contents []antigravityReplayIndexedContent, valid bool, + includeToolPolicy bool, ) *antigravityReplayContextFingerprints { fingerprints := &antigravityReplayContextFingerprints{ valid: valid, @@ -1478,7 +1613,11 @@ func newAntigravityReplayContextFingerprints( fingerprints.sums = []string{""} return fingerprints } - for _, path := range []string{"request.systemInstruction", "request.tools", "request.toolConfig"} { + paths := []string{"request.systemInstruction"} + if includeToolPolicy { + paths = append(paths, "request.tools", "request.toolConfig") + } + for _, path := range paths { if value := util.GetGJSONBytesNoCopy(payload, path); value.Exists() { fingerprints.writeString(path) fingerprints.writeByte(0) @@ -1906,14 +2045,14 @@ func insertAntigravityReasoningReplayItemsWithSchemas(index *antigravityReplayRe // antigravityRemoveThoughtSignatureFromOtherParts may already have // rewritten out, so the index has to be refreshed regardless. if hasSuccessor { - index = newAntigravityReplayRequestIndex(out) + index = newAntigravityReplayRequestIndexWithTracker(out, index.logTracker) } continue } out = updated changed = true if hasSuccessor { - index = newAntigravityReplayRequestIndex(out) + index = newAntigravityReplayRequestIndexWithTracker(out, index.logTracker) } case "function_call_part": updated, ok := mergeAntigravityFunctionCallPartReplayWithSchemas(index, out, itemResult, toolSchemas) @@ -1921,7 +2060,7 @@ func insertAntigravityReasoningReplayItemsWithSchemas(index *antigravityReplayRe out = updated changed = true if hasSuccessor { - index = newAntigravityReplayRequestIndex(out) + index = newAntigravityReplayRequestIndexWithTracker(out, index.logTracker) } } } @@ -2192,6 +2331,14 @@ func newAntigravityReasoningReplayAccumulator(scope antigravityReasoningReplaySc index := newAntigravityReplayRequestIndex(requestPayload) contentIndex, basePartIndex := index.pendingModelContentIndex() items := index.reasoningReplayItemsFromRequest() + responseContextHash := index.contextFingerprint(contentIndex) + if scope.openAIChat { + responseContextHash = index.openAIChatContextFingerprint(contentIndex) + for itemIndex, item := range items { + cachedContentIndex := int(gjson.GetBytes(item, "contentIndex").Int()) + items[itemIndex] = antigravitySetReplayItemContextHashValue(item, index.openAIChatContextFingerprint(cachedContentIndex)) + } + } seenSignatures := make(map[string]bool, len(items)) for _, item := range items { itemResult := gjson.ParseBytes(item) @@ -2221,7 +2368,7 @@ func newAntigravityReasoningReplayAccumulator(scope antigravityReasoningReplaySc } return &antigravityReasoningReplayAccumulator{ scope: scope, - responseContextHash: index.contextFingerprint(contentIndex), + responseContextHash: responseContextHash, items: items, seenFC: make(map[string]bool), seenSignatures: seenSignatures, @@ -2340,11 +2487,42 @@ func (a *antigravityReasoningReplayAccumulator) ObserveSSELine(line []byte) { a.observeResponsePayload(payload) } +func (a *antigravityReasoningReplayAccumulator) claimPrecedingTurnThoughtSignature() string { + if a == nil { + return "" + } + for itemIndex := range a.items { + item := gjson.ParseBytes(a.items[itemIndex]) + if item.Get("type").String() == "function_call_part" && + int(item.Get("contentIndex").Int()) == a.contentIndex && + strings.TrimSpace(item.Get("thoughtSignature").String()) != "" { + return "" + } + } + for itemIndex := len(a.items) - 1; itemIndex >= 0; itemIndex-- { + item := gjson.ParseBytes(a.items[itemIndex]) + if item.Get("type").String() != "thought_signature" || + int(item.Get("contentIndex").Int()) != a.contentIndex || + item.Get("targetKind").String() != "thought" { + continue + } + if sig := strings.TrimSpace(item.Get("thoughtSignature").String()); sig != "" { + return sig + } + } + return "" +} + func (a *antigravityReasoningReplayAccumulator) observeResponsePayload(payload []byte) { if finishReason := strings.TrimSpace(gjson.GetBytes(payload, "response.candidates.0.finishReason").String()); finishReason != "" { a.terminal = true + } else if finishReason := strings.TrimSpace(gjson.GetBytes(payload, "candidates.0.finishReason").String()); finishReason != "" { + a.terminal = true } parts := gjson.GetBytes(payload, "response.candidates.0.content.parts") + if !parts.IsArray() { + parts = gjson.GetBytes(payload, "candidates.0.content.parts") + } if !parts.IsArray() { return } @@ -2377,6 +2555,9 @@ func (a *antigravityReasoningReplayAccumulator) observeResponsePayload(payload [ } } } + if signature == "" { + signature = a.claimPrecedingTurnThoughtSignature() + } keys := antigravityReplayToolCallKeysFromPart(fc) for _, key := range keys { dedupeKey := key + "\x00" + signature @@ -2631,7 +2812,13 @@ func cacheAntigravityReasoningReplayFromResponse(ctx context.Context, scope anti return } acc := newAntigravityReasoningReplayAccumulator(scope, requestPayload) + if acc == nil { + return + } acc.observeResponsePayload(body) + if !acc.terminal && (gjson.GetBytes(body, "response.candidates.0").Exists() || gjson.GetBytes(body, "candidates.0").Exists()) { + acc.terminal = true + } acc.Commit(ctx) } diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 4c99b073..78a7001b 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -7,6 +7,7 @@ import ( "bufio" "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -34,10 +35,7 @@ const ( glAPIVersion = "v1beta" // streamScannerBuffer is the buffer size for SSE stream scanning. - streamScannerBuffer = 52_428_800 - - // geminiInteractionsAPIRevision is the default API revision for native Interactions requests. - geminiInteractionsAPIRevision = "2026-05-20" + streamScannerBuffer = helps.StreamScannerBuffer ) // GeminiExecutor is a stateless executor for the official Gemini API using API keys. @@ -151,7 +149,10 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } originalPayload := originalPayloadSource isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, body := helps.TranslateRequestPairWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, false, isCompat) + originalTranslated, body, err := helps.TranslateRequestPairReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, false, isCompat) + if err != nil { + return resp, err + } body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -161,7 +162,6 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = capGeminiMaxOutputTokens(body, baseModel) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -183,8 +183,9 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } body, _ = sjson.DeleteBytes(body, "session_id") - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedReasoningEffort(body, to.String()) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return resp, err @@ -239,11 +240,22 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.ObserveResponseModel(data) + if blockMsg, blocked := geminiPromptBlockErrorMessage(data); blocked { + err = statusErr{code: http.StatusBadGateway, msg: blockMsg} + helps.RecordAPIResponseError(ctx, e.cfg, err) + reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err) + return cliproxyexecutor.Response{}, err + } var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} } + if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr { + err = statusErr{code: http.StatusBadGateway, msg: errPayload} + reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err) + return cliproxyexecutor.Response{}, err + } reporter.Publish(ctx, helps.ParseGeminiUsage(data)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) @@ -277,7 +289,10 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } originalPayload := originalPayloadSource isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, body := helps.TranslateRequestPairWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat) + originalTranslated, body, err := helps.TranslateRequestPairReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat) + if err != nil { + return nil, err + } body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -287,7 +302,6 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = capGeminiMaxOutputTokens(body, baseModel) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -302,8 +316,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } body, _ = sjson.DeleteBytes(body, "session_id") - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedReasoningEffort(body, to.String()) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err @@ -349,7 +364,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A err = statusErr{code: httpResp.StatusCode, msg: string(b)} return nil, err } - out := make(chan cliproxyexecutor.StreamChunk) + out := make(chan cliproxyexecutor.StreamChunk, 1) go func() { defer close(out) defer reporter.EnsurePublished(ctx) @@ -362,26 +377,60 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A scanner.Buffer(nil, streamScannerBuffer) claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) var streamUsage helps.StreamUsageBuffer - defer streamUsage.Publish(ctx, reporter) + var terminalDelivered bool + defer func() { + if errContext := ctx.Err(); errContext != nil && !terminalDelivered { + streamUsage.PublishFailure(ctx, reporter, errContext) + return + } + streamUsage.Publish(ctx, reporter) + }() + var streamErr error + defer func() { + _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) + }() var param any + var sawFinishReason bool helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) + rawPayload := helps.JSONPayload(line) + if !sawFinishReason && geminiStreamHasFinishSignal(rawPayload) { + sawFinishReason = true + } + if detail, ok := helps.ParseGeminiStreamUsage(rawPayload); ok { + streamUsage.Observe(detail, true) + } + if blockMsg, blocked := geminiPromptBlockErrorMessage(rawPayload); blocked { + blockErr := statusErr{code: http.StatusBadGateway, msg: blockMsg} + helps.RecordAPIResponseError(ctx, e.cfg, blockErr) + if !streamUsage.PublishFailure(ctx, reporter, blockErr) { + reporter.PublishFailure(ctx, blockErr) + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: blockErr}: + case <-ctx.Done(): + } + return + } filtered := helps.FilterSSEUsageMetadata(line) payload := helps.JSONPayload(filtered) if len(payload) == 0 { continue } - if detail, ok := helps.ParseGeminiStreamUsage(payload); ok { - streamUsage.Observe(detail, true) - } lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(payload), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + if helps.IsGeminiTerminalStreamChunk(lines[i]) { + terminalDelivered = true + } case <-ctx.Done(): return } @@ -393,9 +442,15 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return + } if errScan := scanner.Err(); errScan != nil { + if terminalDelivered && errors.Is(errScan, context.Canceled) { + return + } helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx, errScan) + streamUsage.PublishFailure(ctx, reporter, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): @@ -405,11 +460,29 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if ctx.Err() != nil { return } + if (responseFormat == sdktranslator.FormatClaude || responseFormat == sdktranslator.FormatOpenAI) && !sawFinishReason { + truncErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before finishReason", streamTruncated: true} + helps.RecordAPIResponseError(ctx, e.cfg, truncErr) + if !streamUsage.PublishFailure(ctx, reporter, truncErr) { + reporter.PublishFailure(ctx, truncErr) + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: truncErr}: + case <-ctx.Done(): + } + return + } lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + if helps.IsGeminiTerminalStreamChunk(lines[i]) { + terminalDelivered = true + } case <-ctx.Done(): return } @@ -417,10 +490,59 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return + } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil } +func geminiPromptBlockErrorMessage(payload []byte) (string, bool) { + if len(payload) == 0 { + return "", false + } + for _, cPath := range []string{"candidates", "response.candidates"} { + if candidates := gjson.GetBytes(payload, cPath); candidates.IsArray() && len(candidates.Array()) > 0 { + return "", false + } + } + for _, prefix := range []string{"promptFeedback", "response.promptFeedback"} { + reason := strings.TrimSpace(gjson.GetBytes(payload, prefix+".blockReason").String()) + if reason == "" { + continue + } + if detail := strings.TrimSpace(gjson.GetBytes(payload, prefix+".blockReasonMessage").String()); detail != "" { + return fmt.Sprintf("upstream blocked the prompt: %s - %s", reason, detail), true + } + return fmt.Sprintf("upstream blocked the prompt: %s", reason), true + } + return "", false +} + +func geminiStreamHasFinishSignal(payload []byte) bool { + if len(payload) == 0 { + return false + } + for _, path := range []string{ + "candidates.#.finishReason", + "response.candidates.#.finishReason", + "promptFeedback.blockReason", + "response.promptFeedback.blockReason", + } { + res := gjson.GetBytes(payload, path) + if res.IsArray() { + for _, item := range res.Array() { + if strings.TrimSpace(item.String()) != "" { + return true + } + } + } else if res.Exists() && strings.TrimSpace(res.String()) != "" { + return true + } + } + return false +} + func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { targetName := thinking.ParseSuffix(req.Model).ModelName apiKey := geminiAPIKey(auth) @@ -428,7 +550,10 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy defer reporter.TrackFailure(ctx, &err) isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, body := translateGeminiInteractionsRequestPair(ctx, e.cfg, targetName, req.Payload, opts, false, isCompat) + originalTranslated, body, err := translateGeminiInteractionsRequestPair(ctx, e.cfg, targetName, req.Payload, opts, false, isCompat) + if err != nil { + return resp, err + } if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } @@ -439,11 +564,13 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = sanitizeGeminiInteractionsUnsupportedInputIDs(body) + continuation, body := helps.PrepareAntigravityInteractions(auth, req, opts, targetName, body) baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedRequestMetadata(body, "interactions") httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errRequest != nil { return resp, errRequest @@ -492,6 +619,7 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy err = statusErr{code: httpResp.StatusCode, msg: string(data)} return resp, err } + continuation.Observe(data) reporter.ObserveResponseModel(data) targetFormat := cliproxyexecutor.ResponseFormatOrSource(opts) var param any @@ -513,7 +641,10 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl defer reporter.TrackFailure(ctx, &err) isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, body := translateGeminiInteractionsRequestPair(ctx, e.cfg, targetName, req.Payload, opts, true, isCompat) + originalTranslated, body, err := translateGeminiInteractionsRequestPair(ctx, e.cfg, targetName, req.Payload, opts, true, isCompat) + if err != nil { + return nil, err + } if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } @@ -524,11 +655,13 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = sanitizeGeminiInteractionsUnsupportedInputIDs(body) + continuation, body := helps.PrepareAntigravityInteractions(auth, req, opts, targetName, body) body = helps.SetBoolIfDifferent(body, "stream", true) baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedRequestMetadata(body, "interactions") httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errRequest != nil { return nil, errRequest @@ -607,6 +740,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl payload = trimmed } if len(payload) > 0 { + continuation.Observe(payload) reporter.ObserveResponseModel(payload) if detail, ok := helps.ParseInteractionsStreamUsage(payload); ok { streamUsage.Observe(detail, true) @@ -681,9 +815,16 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") - translatedReq := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) + translatedReq, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) + if err != nil { + return cliproxyexecutor.Response{}, err + } + originalTranslatedForPayload := append([]byte(nil), translatedReq...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false, helps.APIKeyModelIsCompat(req)) + } - translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) + translatedReq, err = helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } @@ -700,6 +841,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "countTokens") + translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq) requestBody := bytes.NewReader(translatedReq) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) @@ -850,42 +992,11 @@ func nativeInteractionsSourceFormat(format sdktranslator.Format) bool { // - `function_result` (FunctionResultStep) requires `call_id` and rejects `id` // - other steps and content parts do not support `id` func sanitizeGeminiInteractionsUnsupportedInputIDs(body []byte) []byte { - input := gjson.GetBytes(body, "input") - if !input.IsArray() { - return body - } - for i, item := range input.Array() { - stepType := item.Get("type").String() - if stepType == "function_call" { - if !item.Get("id").Exists() && item.Get("call_id").Exists() { - body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.id", i), item.Get("call_id").String()) - } - if item.Get("call_id").Exists() { - body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.call_id", i)) - } - } else { - if item.Get("id").Exists() { - body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.id", i)) - } - } - content := item.Get("content") - if !content.IsArray() { - continue - } - for j, part := range content.Array() { - if part.Get("id").Exists() { - body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.content.%d.id", i, j)) - } - } - } - return body + return helps.SanitizeGeminiInteractionsUnsupportedInputIDs(body) } -func translateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) []byte { - if opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions { - return bytes.Clone(payload) - } - return helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, cfg, opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream, isCompat) +func translateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) ([]byte, error) { + return helps.TranslateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat) } // translateGeminiInteractionsRequestPair translates the working payload and the @@ -894,19 +1005,12 @@ func translateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Con // working buffer is a separate copy so those mutations cannot change it. Distinct // inputs keep the existing order: working payload first, then the payload-config // source. -func translateGeminiInteractionsRequestPair(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) (original, working []byte) { - source := geminiInteractionsPayloadConfigInput(opts, payload) - if geminiInteractionsSameByteSlice(payload, source) { - original = translateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat) - return original, bytes.Clone(original) - } - working = translateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat) - original = geminiInteractionsPayloadConfigSource(ctx, cfg, model, payload, opts, stream, isCompat) - return original, working +func translateGeminiInteractionsRequestPair(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) (original, working []byte, err error) { + return helps.TranslateGeminiInteractionsRequestPair(ctx, cfg, model, payload, opts, stream, isCompat) } -func geminiInteractionsPayloadConfigSource(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) []byte { - return translateGeminiInteractionsRequestBody(ctx, cfg, model, geminiInteractionsPayloadConfigInput(opts, payload), opts, stream, isCompat) +func geminiInteractionsPayloadConfigSource(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) ([]byte, error) { + return helps.TranslateGeminiInteractionsRequestBody(ctx, cfg, model, geminiInteractionsPayloadConfigInput(opts, payload), opts, stream, isCompat) } func geminiInteractionsPayloadConfigInput(opts cliproxyexecutor.Options, payload []byte) []byte { @@ -937,82 +1041,23 @@ func isNativeInteractionsAuth(auth *cliproxyauth.Auth) bool { } func applyGeminiInteractionsThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) { - fromFormat := opts.SourceFormat.String() - if strings.TrimSpace(fromFormat) == "" { - fromFormat = sdktranslator.FormatInteractions.String() - } - return helps.ApplyRequestThinking(body, req, opts, fromFormat, sdktranslator.FormatInteractions.String(), "gemini") + return helps.ApplyGeminiInteractionsThinking(body, req, opts) } func applyGeminiInteractionsRevisionHeader(req *http.Request) { - if req == nil { - return - } - if req.Header.Get("Api-Revision") == "" { - req.Header.Set("Api-Revision", geminiInteractionsAPIRevision) - } + helps.ApplyGeminiInteractionsRevisionHeader(req) } func applyGeminiInteractionsRequestHeaders(req *http.Request, headers http.Header) { - if req == nil || headers == nil || req.Header.Get("Api-Revision") != "" { - return - } - if revision := headers.Get("Api-Revision"); revision != "" { - req.Header.Set("Api-Revision", revision) - } + helps.ApplyGeminiInteractionsRequestHeaders(req, headers) } func geminiInteractionsSSEPayload(frame []byte) []byte { - trimmed := bytes.TrimSpace(frame) - if len(trimmed) == 0 { - return nil - } - if bytes.HasPrefix(trimmed, []byte("{")) { - return trimmed - } - lines := bytes.Split(frame, []byte{'\n'}) - var payload []byte - for _, line := range lines { - line = bytes.TrimRight(line, "\r") - if !bytes.HasPrefix(bytes.TrimSpace(line), []byte("data:")) { - continue - } - data := bytes.TrimSpace(line[bytes.Index(line, []byte("data:"))+len("data:"):]) - if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { - continue - } - if len(payload) > 0 { - payload = append(payload, '\n') - } - payload = append(payload, data...) - } - if len(payload) == 0 { - return nil - } - return payload + return helps.GeminiInteractionsSSEPayload(frame) } func geminiInteractionsSSEDone(frame []byte) bool { - trimmed := bytes.TrimSpace(frame) - if bytes.Equal(trimmed, []byte("[DONE]")) { - return true - } - lines := bytes.Split(frame, []byte{'\n'}) - sawDoneEvent := false - for _, line := range lines { - line = bytes.TrimSpace(bytes.TrimRight(line, "\r")) - if bytes.EqualFold(line, []byte("event: done")) { - sawDoneEvent = true - continue - } - if bytes.HasPrefix(line, []byte("data:")) { - data := bytes.TrimSpace(line[len("data:"):]) - if bytes.Equal(data, []byte("[DONE]")) { - return true - } - } - } - return sawDoneEvent + return helps.GeminiInteractionsSSEDone(frame) } func geminiAuthLogFields(auth *cliproxyauth.Auth) (string, string, string, string) { diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 3054d707..4ba7684e 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -240,6 +241,12 @@ func (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.A if opts.Alt == "responses/compact" { return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} } + if helps.ShouldExecuteVertexOpenAPIForRequest(e.cfg, auth, req.Model, opts) { + return e.executeOpenAPI(ctx, auth, req, opts) + } + if shouldExecuteVertexInteractions(auth, opts) { + return e.executeInteractions(ctx, auth, req, opts) + } // Try API key authentication first apiKey, baseURL := vertexAPICreds(auth) @@ -262,6 +269,12 @@ func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if opts.Alt == "responses/compact" { return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} } + if helps.ShouldExecuteVertexOpenAPIForRequest(e.cfg, auth, req.Model, opts) { + return e.executeOpenAPIStream(ctx, auth, req, opts) + } + if shouldExecuteVertexInteractions(auth, opts) { + return e.executeInteractionsStream(ctx, auth, req, opts) + } // Try API key authentication first apiKey, baseURL := vertexAPICreds(auth) @@ -280,6 +293,10 @@ func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxy // CountTokens counts tokens for the given request using the Vertex AI API. func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + if helps.ShouldExecuteVertexOpenAPIForRequest(e.cfg, auth, req.Model, opts) { + normalizedReq, normalizedOpts := normalizeVertexOpenAPIRequest(req, opts) + return NewOpenAICompatExecutor(e.Identifier(), e.cfg).CountTokens(ctx, auth, normalizedReq, normalizedOpts) + } // Try API key authentication first apiKey, baseURL := vertexAPICreds(auth) @@ -296,6 +313,91 @@ func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyau return e.countTokensWithAPIKey(ctx, auth, req, opts, apiKey, baseURL) } +func normalizeVertexOpenAPIRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Request, cliproxyexecutor.Options) { + normalizedModel := helps.NormalizeVertexOpenAPIModel(req.Model) + if normalizedModel == "" || normalizedModel == req.Model { + return req, opts + } + if len(opts.OriginalRequest) == 0 && len(req.Payload) > 0 { + opts.OriginalRequest = req.Payload + } + req.Model = normalizedModel + return req, opts +} + +func hasVertexServiceAccount(auth *cliproxyauth.Auth) bool { + if auth == nil || auth.Metadata == nil { + return false + } + sa, ok := auth.Metadata["service_account"].(map[string]any) + return ok && len(sa) > 0 +} + +func (e *GeminiVertexExecutor) vertexOpenAPILocation(auth *cliproxyauth.Auth, defaultLocation string) string { + if explicit := helps.VertexExplicitOpenAPILocation(e.cfg, auth); explicit != "" { + return explicit + } + return helps.VertexOpenAPILocation(auth, defaultLocation) +} + +func (e *GeminiVertexExecutor) resolveOpenAPICredentials(ctx context.Context, auth *cliproxyauth.Auth) (string, string, error) { + apiKey, baseURL := vertexAPICreds(auth) + if apiKey == "" || hasVertexServiceAccount(auth) { + projectID, location, saJSON, errCreds := vertexCreds(auth) + if errCreds != nil { + return "", "", errCreds + } + token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON) + if errTok != nil { + log.Errorf("vertex executor: access token error: %v", errTok) + return "", "", statusErr{code: 500, msg: "internal server error"} + } + if strings.TrimSpace(token) == "" { + return "", "", statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + authBaseURL := helps.VertexAuthBaseURL(auth) + openAPILocation := e.vertexOpenAPILocation(auth, location) + if strings.Contains(authBaseURL, "/locations/") { + openAPILocation = helps.VertexExplicitOpenAPILocation(e.cfg, auth) + } + openAPIBaseURL := helps.VertexServiceAccountOpenAPIBaseURL(authBaseURL, vertexAPIVersion, projectID, openAPILocation) + return openAPIBaseURL, token, nil + } + if helps.IsGoogleVertexHost(baseURL) { + return "", "", statusErr{code: http.StatusBadRequest, msg: "vertex endpoints/openapi on aiplatform.googleapis.com requires service account credentials"} + } + openAPILocation := helps.VertexExplicitOpenAPILocation(e.cfg, auth) + if !strings.Contains(baseURL, "/locations/") && openAPILocation == "" { + openAPILocation = e.vertexOpenAPILocation(auth, "") + } + openAPIBaseURL := helps.VertexAPIKeyOpenAPIBaseURL(baseURL, vertexAPIVersion, helps.VertexAuthProjectID(auth), openAPILocation) + return openAPIBaseURL, apiKey, nil +} + +func (e *GeminiVertexExecutor) executeOpenAPI(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + req, opts = normalizeVertexOpenAPIRequest(req, opts) + openAPIBaseURL, token, errCreds := e.resolveOpenAPICredentials(ctx, auth) + if errCreds != nil { + baseModel := thinking.ParseSuffix(req.Model).ModelName + reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) + reporter.TrackFailure(ctx, &errCreds) + return resp, errCreds + } + return NewOpenAICompatExecutor(e.Identifier(), e.cfg).executeWithCredentials(ctx, auth, req, opts, openAPIBaseURL, token, nil) +} + +func (e *GeminiVertexExecutor) executeOpenAPIStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + req, opts = normalizeVertexOpenAPIRequest(req, opts) + openAPIBaseURL, token, errCreds := e.resolveOpenAPICredentials(ctx, auth) + if errCreds != nil { + baseModel := thinking.ParseSuffix(req.Model).ModelName + reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) + reporter.TrackFailure(ctx, &errCreds) + return nil, errCreds + } + return NewOpenAICompatExecutor(e.Identifier(), e.cfg).executeStreamWithCredentials(ctx, auth, req, opts, openAPIBaseURL, token, nil) +} + // Refresh refreshes the authentication credentials (no-op for Vertex). func (e *GeminiVertexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { @@ -313,6 +415,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au defer reporter.TrackFailure(ctx, &err) var body []byte + var originalTranslated []byte // Handle Imagen models with special request format if isImagenModel(baseModel) { @@ -321,6 +424,13 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au return resp, errImagen } body = imagenBody + originalTranslated = append([]byte(nil), imagenBody...) + if len(opts.OriginalRequest) > 0 { + originalTranslated, errImagen = convertToImagenRequest(opts.OriginalRequest) + if errImagen != nil { + return resp, errImagen + } + } } else { // Standard Gemini translation flow from := opts.SourceFormat @@ -331,8 +441,11 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) - body = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + originalTranslated = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) + body, err = helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, false) + if err != nil { + return resp, err + } body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -340,9 +453,6 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au } body = fixGeminiImageAspectRatio(baseModel, body) - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -356,6 +466,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au } body = helps.EnsureGeminiLeadingUserContent(body, "contents") if action != "countTokens" { + body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents") body = helps.EnsureGeminiTrailingUserContent(body, "contents") } baseURL := vertexBaseURL(location) @@ -364,8 +475,9 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au url = url + fmt.Sprintf("?$alt=%s", opts.Alt) } body, _ = sjson.DeleteBytes(body, "session_id") - reporter.SetTranslatedReasoningEffort(body, "gemini") + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "gemini", opts.SourceFormat.String(), "", body, originalTranslated, helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers) + reporter.SetTranslatedReasoningEffort(body, "gemini") httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return resp, errNewReq @@ -436,6 +548,13 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au data = convertImagenToGeminiResponse(data, baseModel) } + if blockMsg, blocked := geminiPromptBlockErrorMessage(data); blocked { + err = statusErr{code: http.StatusBadGateway, msg: blockMsg} + helps.RecordAPIResponseError(ctx, e.cfg, err) + reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err) + return cliproxyexecutor.Response{}, err + } + // Standard Gemini translation (works for both Gemini and converted Imagen responses) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") @@ -444,6 +563,11 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} } + if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr { + err = statusErr{code: http.StatusBadGateway, msg: errPayload} + reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err) + return cliproxyexecutor.Response{}, err + } reporter.Publish(ctx, helps.ParseGeminiUsage(data)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) @@ -469,7 +593,10 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip } originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, false) + if err != nil { + return resp, err + } body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -479,7 +606,6 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -492,20 +618,22 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip } body = helps.EnsureGeminiLeadingUserContent(body, "contents") if action != "countTokens" { + body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents") body = helps.EnsureGeminiTrailingUserContent(body, "contents") } - // For API key auth, use simpler URL format without project/location - if baseURL == "" { - baseURL = "https://aiplatform.googleapis.com" - } - url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action) + // For API key auth, use simpler URL format without project/location. + // The helper honors base URLs that already encode "/publishers/..." so + // configurations like ".../v1/projects/P/locations/L/publishers/google/models" + // keep working without a duplicated "/v1" segment. + url := vertexAPIKeyModelsURL(baseURL, baseModel, action) if opts.Alt != "" && action != "countTokens" { url = url + fmt.Sprintf("?$alt=%s", opts.Alt) } body, _ = sjson.DeleteBytes(body, "session_id") - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedReasoningEffort(body, to.String()) httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return resp, errNewReq @@ -566,11 +694,22 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip } helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.ObserveResponseModel(data) + if blockMsg, blocked := geminiPromptBlockErrorMessage(data); blocked { + err = statusErr{code: http.StatusBadGateway, msg: blockMsg} + helps.RecordAPIResponseError(ctx, e.cfg, err) + reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err) + return cliproxyexecutor.Response{}, err + } var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} } + if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr { + err = statusErr{code: http.StatusBadGateway, msg: errPayload} + reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err) + return cliproxyexecutor.Response{}, err + } reporter.Publish(ctx, helps.ParseGeminiUsage(data)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) @@ -596,7 +735,10 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte } originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) + body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true, false) + if err != nil { + return nil, err + } body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -606,12 +748,12 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") action := getVertexAction(baseModel, true) + body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents") body = helps.EnsureGeminiBoundaryUserContent(body, "contents") baseURL := vertexBaseURL(location) url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action) @@ -624,8 +766,9 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte } } body, _ = sjson.DeleteBytes(body, "session_id") - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedReasoningEffort(body, to.String()) httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return nil, errNewReq @@ -680,7 +823,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte return nil, statusErr{code: httpResp.StatusCode, msg: string(b)} } - out := make(chan cliproxyexecutor.StreamChunk) + out := make(chan cliproxyexecutor.StreamChunk, 1) go func() { defer close(out) defer reporter.EnsurePublished(ctx) @@ -694,20 +837,48 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) var streamUsage helps.StreamUsageBuffer defer streamUsage.Publish(ctx, reporter) + var streamErr error + defer func() { + _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) + }() + var terminalDelivered bool + var sawFinishReason bool var param any helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) + rawPayload := helps.JSONPayload(line) + if !sawFinishReason && geminiStreamHasFinishSignal(rawPayload) { + sawFinishReason = true + } if detail, ok := helps.ParseGeminiStreamUsage(line); ok { streamUsage.Observe(detail, true) } + if blockMsg, blocked := geminiPromptBlockErrorMessage(rawPayload); blocked { + blockErr := statusErr{code: http.StatusBadGateway, msg: blockMsg} + helps.RecordAPIResponseError(ctx, e.cfg, blockErr) + if !streamUsage.PublishFailure(ctx, reporter, blockErr) { + reporter.PublishFailure(ctx, blockErr) + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: blockErr}: + case <-ctx.Done(): + } + return + } lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + if helps.IsGeminiTerminalStreamChunk(lines[i]) { + terminalDelivered = true + } case <-ctx.Done(): return } @@ -719,7 +890,13 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return + } if errScan := scanner.Err(); errScan != nil { + if terminalDelivered && errors.Is(errScan, context.Canceled) { + return + } helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) select { @@ -731,8 +908,23 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte if ctx.Err() != nil { return } + if (responseFormat == sdktranslator.FormatClaude || responseFormat == sdktranslator.FormatOpenAI) && !sawFinishReason { + truncErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before finishReason", streamTruncated: true} + helps.RecordAPIResponseError(ctx, e.cfg, truncErr) + if !streamUsage.PublishFailure(ctx, reporter, truncErr) { + reporter.PublishFailure(ctx, truncErr) + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: truncErr}: + case <-ctx.Done(): + } + return + } lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -743,6 +935,9 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return + } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil } @@ -764,7 +959,10 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth } originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) + body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true, false) + if err != nil { + return nil, err + } body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -774,18 +972,15 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") action := getVertexAction(baseModel, true) + body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents") body = helps.EnsureGeminiBoundaryUserContent(body, "contents") // For API key auth, use simpler URL format without project/location - if baseURL == "" { - baseURL = "https://aiplatform.googleapis.com" - } - url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action) + url := vertexAPIKeyModelsURL(baseURL, baseModel, action) // Imagen models don't support streaming, skip SSE params if !isImagenModel(baseModel) { if opts.Alt == "" { @@ -795,8 +990,9 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth } } body, _ = sjson.DeleteBytes(body, "session_id") - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedReasoningEffort(body, to.String()) httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return nil, errNewReq @@ -848,7 +1044,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth return nil, statusErr{code: httpResp.StatusCode, msg: string(b)} } - out := make(chan cliproxyexecutor.StreamChunk) + out := make(chan cliproxyexecutor.StreamChunk, 1) go func() { defer close(out) defer reporter.EnsurePublished(ctx) @@ -862,20 +1058,48 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) var streamUsage helps.StreamUsageBuffer defer streamUsage.Publish(ctx, reporter) + var streamErr error + defer func() { + _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) + }() + var terminalDelivered bool + var sawFinishReason bool var param any helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) + rawPayload := helps.JSONPayload(line) + if !sawFinishReason && geminiStreamHasFinishSignal(rawPayload) { + sawFinishReason = true + } if detail, ok := helps.ParseGeminiStreamUsage(line); ok { streamUsage.Observe(detail, true) } + if blockMsg, blocked := geminiPromptBlockErrorMessage(rawPayload); blocked { + blockErr := statusErr{code: http.StatusBadGateway, msg: blockMsg} + helps.RecordAPIResponseError(ctx, e.cfg, blockErr) + if !streamUsage.PublishFailure(ctx, reporter, blockErr) { + reporter.PublishFailure(ctx, blockErr) + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: blockErr}: + case <-ctx.Done(): + } + return + } lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + if helps.IsGeminiTerminalStreamChunk(lines[i]) { + terminalDelivered = true + } case <-ctx.Done(): return } @@ -887,7 +1111,13 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return + } if errScan := scanner.Err(); errScan != nil { + if terminalDelivered && errors.Is(errScan, context.Canceled) { + return + } helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) select { @@ -899,8 +1129,23 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth if ctx.Err() != nil { return } + if (responseFormat == sdktranslator.FormatClaude || responseFormat == sdktranslator.FormatOpenAI) && !sawFinishReason { + truncErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before finishReason", streamTruncated: true} + helps.RecordAPIResponseError(ctx, e.cfg, truncErr) + if !streamUsage.PublishFailure(ctx, reporter, truncErr) { + reporter.PublishFailure(ctx, truncErr) + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: truncErr}: + case <-ctx.Done(): + } + return + } lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error { + return statusErr{code: http.StatusBadGateway, msg: msg} + }) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -911,6 +1156,9 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } + if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) { + return + } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil } @@ -923,9 +1171,16 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") - translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + translatedReq, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, false) + if err != nil { + return cliproxyexecutor.Response{}, err + } + originalTranslatedForPayload := append([]byte(nil), translatedReq...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false) + } - translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) + translatedReq, err = helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } @@ -943,6 +1198,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context baseURL := vertexBaseURL(location) url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, "countTokens") + translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq) httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq)) if errNewReq != nil { return cliproxyexecutor.Response{}, errNewReq @@ -1017,9 +1273,16 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") - translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + translatedReq, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, false) + if err != nil { + return cliproxyexecutor.Response{}, err + } + originalTranslatedForPayload := append([]byte(nil), translatedReq...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false) + } - translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) + translatedReq, err = helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } @@ -1035,11 +1298,9 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents") // For API key auth, use simpler URL format without project/location - if baseURL == "" { - baseURL = "https://aiplatform.googleapis.com" - } - url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "countTokens") + url := vertexAPIKeyModelsURL(baseURL, baseModel, "countTokens") + translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq) httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq)) if errNewReq != nil { return cliproxyexecutor.Response{}, errNewReq @@ -1123,7 +1384,7 @@ func vertexCreds(a *cliproxyauth.Auth) (projectID, location string, serviceAccou if v, ok := a.Metadata["location"].(string); ok && strings.TrimSpace(v) != "" { location = strings.TrimSpace(v) } else { - location = "us-central1" + location = "global" } var sa map[string]any if raw, ok := a.Metadata["service_account"].(map[string]any); ok { @@ -1160,12 +1421,60 @@ func vertexAPICreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { return } +// vertexAPIKeyModelsURL builds the API-key models endpoint URL. +// +// The configured base URL is treated as a full resource prefix when it +// already contains a "/publishers/" segment (e.g. a real Vertex endpoint +// such as "https://aiplatform.googleapis.com/v1/projects/{p}/locations/{l}/publishers/google/models"), +// in which case only "/{model}:{action}" is appended. Otherwise the default +// "/v1/publishers/google/models" segment is inserted so short third-party +// hosts keep working; if the base already ends in a version segment such +// as "/v1" or "/v1beta1", only "/publishers/google/models" is appended to +// avoid producing a duplicated version path. +func vertexAPIKeyModelsURL(baseURL, model, action string) string { + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if base == "" { + base = "https://aiplatform.googleapis.com" + } + if strings.Contains(base, "/publishers/") { + return fmt.Sprintf("%s/%s:%s", base, model, action) + } + if !vertexAPIVersionSuffix(base) { + base = fmt.Sprintf("%s/%s", base, vertexAPIVersion) + } + return fmt.Sprintf("%s/publishers/google/models/%s:%s", base, model, action) +} + +// vertexAPIVersionSuffix reports whether the trimmed base URL already ends +// with a Google API version segment like "/v1" or "/v1beta1". +func vertexAPIVersionSuffix(base string) bool { + if base == "" { + return false + } + seg := base[strings.LastIndexByte(base, '/')+1:] + if len(seg) < 2 || seg[0] != 'v' { + return false + } + hasDigit := false + for i := 1; i < len(seg); i++ { + c := seg[i] + switch { + case c >= '0' && c <= '9': + hasDigit = true + case c == '_' || c == '-' || (c >= 'a' && c <= 'z'): + default: + return false + } + } + return hasDigit +} + func vertexBaseURL(location string) string { loc := strings.TrimSpace(location) - if loc == "" { - loc = "us-central1" - } else if loc == "global" { + if loc == "" || strings.EqualFold(loc, "global") { return "https://aiplatform.googleapis.com" + } else if strings.EqualFold(loc, "us") || strings.EqualFold(loc, "eu") { + return fmt.Sprintf("https://aiplatform.%s.rep.googleapis.com", strings.ToLower(loc)) } return fmt.Sprintf("https://%s-aiplatform.googleapis.com", loc) } @@ -1191,43 +1500,391 @@ func vertexAccessToken(ctx context.Context, cfg *config.Config, auth *cliproxyau // resolveVertexConfig finds the matching vertex-api-key configuration entry for the given auth. func (e *GeminiVertexExecutor) resolveVertexConfig(auth *cliproxyauth.Auth) *config.VertexCompatKey { - if auth == nil || e.cfg == nil { - return nil + return helps.ResolveVertexCompatConfig(e.cfg, auth) +} + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *GeminiVertexExecutor) SupportsApplyPatch() bool { return e != nil } + +func shouldExecuteVertexInteractions(auth *cliproxyauth.Auth, opts cliproxyexecutor.Options) bool { + return opts.SourceFormat == sdktranslator.FormatInteractions && isNativeVertexInteractionsAuth(auth) +} + +func isNativeVertexInteractionsAuth(auth *cliproxyauth.Auth) bool { + if auth == nil { + return false } - var attrKey, attrBase string if auth.Attributes != nil { - attrKey = strings.TrimSpace(auth.Attributes["api_key"]) - attrBase = strings.TrimSpace(auth.Attributes["base_url"]) - } - for i := range e.cfg.VertexCompatAPIKey { - entry := &e.cfg.VertexCompatAPIKey[i] - cfgKey := strings.TrimSpace(entry.APIKey) - cfgBase := strings.TrimSpace(entry.BaseURL) - if attrKey != "" && attrBase != "" { - if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) { - return entry - } - continue + if v := strings.TrimSpace(auth.Attributes["interactions"]); strings.EqualFold(v, "true") || strings.EqualFold(v, "1") { + return true } - if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { - if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { - return entry - } + } + if auth.Metadata != nil { + if v, ok := auth.Metadata["interactions"].(bool); ok && v { + return true } - if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { - return entry + if v, ok := auth.Metadata["native_interactions"].(bool); ok && v { + return true } } - if attrKey != "" { - for i := range e.cfg.VertexCompatAPIKey { - entry := &e.cfg.VertexCompatAPIKey[i] - if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) { - return entry - } + return false +} + +func vertexInteractionsURL(baseURL, projectID string, isStream bool) string { + base := strings.TrimSpace(baseURL) + if base == "" { + base = "https://aiplatform.googleapis.com" + } + base = strings.TrimRight(base, "/") + var url string + if strings.TrimSpace(projectID) != "" { + url = fmt.Sprintf("%s/v1beta1/projects/%s/locations/global/interactions", base, strings.TrimSpace(projectID)) + } else { + url = fmt.Sprintf("%s/v1beta1/interactions", base) + } + if isStream { + url = url + "?alt=sse" + } + return url +} + +func (e *GeminiVertexExecutor) executeInteractions(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + targetName := thinking.ParseSuffix(req.Model).ModelName + reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) + defer reporter.TrackFailure(ctx, &err) + + isCompat := helps.APIKeyModelIsCompat(req) + originalTranslated, body, errTranslate := helps.TranslateGeminiInteractionsRequestPair(ctx, e.cfg, targetName, req.Payload, opts, false, isCompat) + if errTranslate != nil { + return resp, errTranslate + } + if gjson.GetBytes(body, "model").Exists() && targetName != "" { + body = helps.SetStringIfDifferent(body, "model", targetName) + } + var errThinking error + body, errThinking = helps.ApplyGeminiInteractionsThinking(body, req, opts) + if errThinking != nil { + return resp, errThinking + } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + requestPath := helps.PayloadRequestPath(opts) + fromProtocol := opts.SourceFormat.String() + body = helps.SanitizeGeminiInteractionsUnsupportedInputIDs(body) + + apiKey, baseURL := vertexAPICreds(auth) + var projectID string + var saJSON []byte + if apiKey == "" { + var errCreds error + projectID, _, saJSON, errCreds = vertexCreds(auth) + if errCreds != nil { + return resp, errCreds + } + if auth != nil && auth.Attributes != nil && auth.Attributes["base_url"] != "" { + baseURL = strings.TrimSpace(auth.Attributes["base_url"]) + } + } else if auth != nil && auth.Metadata != nil { + if pid, ok := auth.Metadata["project_id"].(string); ok { + projectID = pid } } - return nil + + url := vertexInteractionsURL(baseURL, projectID, false) + + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedRequestMetadata(body, "interactions") + + httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if errRequest != nil { + return resp, errRequest + } + httpReq.Header.Set("Content-Type", "application/json") + if apiKey != "" { + httpReq.Header.Set("x-goog-api-key", apiKey) + } else { + token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON) + if errTok != nil { + log.Errorf("vertex executor: interactions access token error: %v", errTok) + return resp, statusErr{code: 500, msg: "internal server error"} + } + if strings.TrimSpace(token) == "" { + return resp, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + httpReq.Header.Set("Authorization", "Bearer "+token) + } + applyGeminiHeaders(httpReq, auth, opts.Headers) + helps.ApplyGeminiInteractionsRequestHeaders(httpReq, opts.Headers) + helps.ApplyGeminiInteractionsRevisionHeader(httpReq) + + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers) + + var authID, authLabel, authType, authValue string + if auth != nil { + authID = auth.ID + authLabel = auth.Label + authType, authValue = auth.AccountInfo() + } + helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ + URL: url, + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: body, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errDo) + return resp, errDo + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("vertex executor: close interactions response body error: %v", errClose) + } + }() + helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + data, errRead := io.ReadAll(httpResp.Body) + if errRead != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errRead) + return resp, errRead + } + helps.AppendAPIResponseChunk(ctx, e.cfg, data) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) + err = statusErr{code: httpResp.StatusCode, msg: string(data)} + return resp, err + } + reporter.ObserveResponseModel(data) + targetFormat := cliproxyexecutor.ResponseFormatOrSource(opts) + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, targetFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseInteractionsUsage(data)) + if targetFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } + return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil } -// SupportsApplyPatch reports the actual executor contract, independent of its provider name. -func (e *GeminiVertexExecutor) SupportsApplyPatch() bool { return e != nil } +func (e *GeminiVertexExecutor) executeInteractionsStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + targetName := thinking.ParseSuffix(req.Model).ModelName + reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) + defer reporter.TrackFailure(ctx, &err) + + isCompat := helps.APIKeyModelIsCompat(req) + originalTranslated, body, errTranslate := helps.TranslateGeminiInteractionsRequestPair(ctx, e.cfg, targetName, req.Payload, opts, true, isCompat) + if errTranslate != nil { + return nil, errTranslate + } + if gjson.GetBytes(body, "model").Exists() && targetName != "" { + body = helps.SetStringIfDifferent(body, "model", targetName) + } + var errThinking error + body, errThinking = helps.ApplyGeminiInteractionsThinking(body, req, opts) + if errThinking != nil { + return nil, errThinking + } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + requestPath := helps.PayloadRequestPath(opts) + fromProtocol := opts.SourceFormat.String() + body = helps.SanitizeGeminiInteractionsUnsupportedInputIDs(body) + body = helps.SetBoolIfDifferent(body, "stream", true) + + apiKey, baseURL := vertexAPICreds(auth) + var projectID string + var saJSON []byte + if apiKey == "" { + var errCreds error + projectID, _, saJSON, errCreds = vertexCreds(auth) + if errCreds != nil { + return nil, errCreds + } + if auth != nil && auth.Attributes != nil && auth.Attributes["base_url"] != "" { + baseURL = strings.TrimSpace(auth.Attributes["base_url"]) + } + } else if auth != nil && auth.Metadata != nil { + if pid, ok := auth.Metadata["project_id"].(string); ok { + projectID = pid + } + } + + url := vertexInteractionsURL(baseURL, projectID, true) + + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedRequestMetadata(body, "interactions") + + httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if errRequest != nil { + return nil, errRequest + } + httpReq.Header.Set("Content-Type", "application/json") + if apiKey != "" { + httpReq.Header.Set("x-goog-api-key", apiKey) + } else { + token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON) + if errTok != nil { + log.Errorf("vertex executor: interactions access token error: %v", errTok) + return nil, statusErr{code: 500, msg: "internal server error"} + } + if strings.TrimSpace(token) == "" { + return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + httpReq.Header.Set("Authorization", "Bearer "+token) + } + applyGeminiHeaders(httpReq, auth, opts.Headers) + helps.ApplyGeminiInteractionsRequestHeaders(httpReq, opts.Headers) + helps.ApplyGeminiInteractionsRevisionHeader(httpReq) + + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers) + + var authID, authLabel, authType, authValue string + if auth != nil { + authID = auth.ID + authLabel = auth.Label + authType, authValue = auth.AccountInfo() + } + helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ + URL: url, + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: body, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errDo) + return nil, errDo + } + helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + data, _ := io.ReadAll(httpResp.Body) + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("vertex executor: close interactions error response body error: %v", errClose) + } + helps.AppendAPIResponseChunk(ctx, e.cfg, data) + return nil, statusErr{code: httpResp.StatusCode, msg: string(data)} + } + + out := make(chan cliproxyexecutor.StreamChunk) + responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) + go func() { + defer close(out) + defer reporter.EnsurePublished(ctx) + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("vertex executor: close interactions stream body error: %v", errClose) + } + }() + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(nil, helps.StreamScannerBuffer) + originalRequest := opts.OriginalRequest + if len(originalRequest) == 0 { + originalRequest = req.Payload + } + claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, sdktranslator.FormatInteractions, responseFormat, originalRequest) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) + var param any + helps.InitializeApplyPatchStream(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) + var frame []byte + emitFrame := func() bool { + rawFrame := bytes.Clone(frame) + trimmed := bytes.TrimSpace(rawFrame) + frame = frame[:0] + if len(trimmed) == 0 { + return true + } + payload := helps.GeminiInteractionsSSEPayload(rawFrame) + if len(payload) == 0 && helps.GeminiInteractionsSSEDone(rawFrame) { + payload = []byte("[DONE]") + } + if len(payload) > 0 { + reporter.ObserveResponseModel(payload) + if detail, ok := helps.ParseInteractionsStreamUsage(payload); ok { + streamUsage.Observe(detail, true) + } + } + if responseFormat == sdktranslator.FormatInteractions { + visibleFrame := append(bytes.TrimRight(rawFrame, "\r\n"), '\n', '\n') + select { + case out <- cliproxyexecutor.StreamChunk{Payload: visibleFrame}: + case <-ctx.Done(): + return false + } + return true + } + if len(payload) == 0 { + return true + } + var lines [][]byte + lines = helps.TranslateStreamWithClaudeInputTokens(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, payload, ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + for i := range lines { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return false + } + } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } + return true + } + for scanner.Scan() { + line := scanner.Bytes() + helps.AppendAPIResponseChunk(ctx, e.cfg, line) + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + if !emitFrame() { + return + } + continue + } + if len(frame) > 0 { + frame = append(frame, '\n') + } + frame = append(frame, line...) + } + if !emitFrame() { + return + } + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + if errScan := scanner.Err(); errScan != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errScan) + reporter.PublishFailure(ctx, errScan) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } + } + }() + + return &cliproxyexecutor.StreamResult{ + Chunks: out, + Headers: httpResp.Header.Clone(), + }, nil +} diff --git a/internal/runtime/executor/helps/antigravity_compaction.go b/internal/runtime/executor/helps/antigravity_compaction.go index 60d4329d..a550c934 100644 --- a/internal/runtime/executor/helps/antigravity_compaction.go +++ b/internal/runtime/executor/helps/antigravity_compaction.go @@ -13,6 +13,8 @@ import ( "strings" "time" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -28,6 +30,11 @@ type antigravityCompactionCapsuleData struct { CreatedAt int64 `json:"created_at"` } +// RecognizedAntigravityCompactionCapsule reports whether encrypted content uses the CPA capsule prefix. +func RecognizedAntigravityCompactionCapsule(encryptedContent string) bool { + return strings.HasPrefix(strings.TrimSpace(encryptedContent), antigravityCompactionCapsulePrefix) +} + // HasResponsesCompactionTrigger checks whether input contains a compaction_trigger item. func HasResponsesCompactionTrigger(payload []byte) bool { input := gjson.GetBytes(payload, "input") @@ -56,6 +63,76 @@ func HasResponsesCompactionItem(payload []byte) bool { return false } +// HasResponsesCompactionTriggerRequest reports whether req.Payload or opts.OriginalRequest contains a compaction_trigger item. +func HasResponsesCompactionTriggerRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) bool { + return HasResponsesCompactionTrigger(req.Payload) || HasResponsesCompactionTrigger(opts.OriginalRequest) +} + +// ResponsesCompactionSourcePayload selects the source payload containing the compaction trigger or request history. +func ResponsesCompactionSourcePayload(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) []byte { + payload := req.Payload + if !HasResponsesCompactionTrigger(payload) && HasResponsesCompactionTrigger(opts.OriginalRequest) { + payload = opts.OriginalRequest + } + if len(payload) == 0 && len(opts.OriginalRequest) > 0 { + payload = opts.OriginalRequest + } + return payload +} + +// DropForeignResponsesCompactionItems removes compaction items whose encrypted_content does not use the CPA capsule prefix. +func DropForeignResponsesCompactionItems(payload []byte, provider string) []byte { + input := gjson.GetBytes(payload, "input") + if !input.IsArray() { + return payload + } + dropped := 0 + items := make([]string, 0, len(input.Array())) + for _, item := range input.Array() { + if item.Get("type").String() == "compaction" && !RecognizedAntigravityCompactionCapsule(item.Get("encrypted_content").String()) { + dropped++ + continue + } + items = append(items, item.Raw) + } + if dropped == 0 { + return payload + } + log.Warnf("%s compaction: dropped %d non-CPA compaction item(s)", provider, dropped) + out, _ := sjson.SetRawBytes(payload, "input", []byte("["+strings.Join(items, ",")+"]")) + return out +} + +// ExpandResponsesCompactionCapsules drops foreign compaction items and expands recognized CPA compaction capsules in req and opts. +func ExpandResponsesCompactionCapsules(req *cliproxyexecutor.Request, opts *cliproxyexecutor.Options, provider string) error { + sameOriginal := req != nil && opts != nil && len(opts.OriginalRequest) > 0 && bytes.Equal(req.Payload, opts.OriginalRequest) + if req != nil { + req.Payload = DropForeignResponsesCompactionItems(req.Payload, provider) + if HasResponsesCompactionItem(req.Payload) { + expanded, errExpand := ExpandAntigravityCompactionCapsules(req.Payload) + if errExpand != nil { + return errExpand + } + req.Payload = expanded + } + } + if opts != nil && len(opts.OriginalRequest) > 0 { + if sameOriginal { + opts.OriginalRequest = req.Payload + return nil + } + opts.OriginalRequest = DropForeignResponsesCompactionItems(opts.OriginalRequest, provider) + if HasResponsesCompactionItem(opts.OriginalRequest) { + expanded, errExpand := ExpandAntigravityCompactionCapsules(opts.OriginalRequest) + if errExpand != nil { + return errExpand + } + opts.OriginalRequest = expanded + } + } + return nil +} + // PrepareAntigravityCompactionSummaryPayload prepares a payload for non-stream Antigravity summary generation. func PrepareAntigravityCompactionSummaryPayload(payload []byte, modelName string) []byte { out := payload @@ -225,7 +302,8 @@ func ExtractAntigravitySummaryText(respPayload []byte) (string, error) { content := item.Get("content") if content.IsArray() { for _, part := range content.Array() { - if part.Get("type").String() == "output_text" { + partType := part.Get("type").String() + if partType == "output_text" || partType == "text" { if t := part.Get("text").String(); t != "" { textParts = append(textParts, t) } @@ -372,6 +450,115 @@ func BuildAntigravityCompactionResponse(modelName, capsule string, inputTokens, return respJSON } +// ExtractResponsesCompactionUsage extracts usage counters from an OpenAI Responses summary payload. +func ExtractResponsesCompactionUsage(payload []byte) (inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens int) { + if detail, ok := ParseCodexUsage(payload); ok && (detail.TotalTokens > 0 || detail.InputTokens > 0 || detail.OutputTokens > 0) { + return int(detail.InputTokens), int(detail.OutputTokens), int(detail.TotalTokens), int(detail.CachedTokens), int(detail.ReasoningTokens) + } + parsed := ParseOpenAIUsage(payload) + return int(parsed.InputTokens), int(parsed.OutputTokens), int(parsed.TotalTokens), int(parsed.CachedTokens), int(parsed.ReasoningTokens) +} + +// PatchResponsesCompactionStreamUsage updates usage counters on a compaction SSE chunk. +func PatchResponsesCompactionStreamUsage(chunk []byte, inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens int) []byte { + const dataPrefix = "data: " + idx := bytes.Index(chunk, []byte(dataPrefix)) + if idx < 0 { + return chunk + } + data := bytes.TrimSpace(chunk[idx+len(dataPrefix):]) + path := "" + switch { + case gjson.GetBytes(data, "response.usage").Exists(): + path = "response.usage" + case gjson.GetBytes(data, "usage").Exists(): + path = "usage" + default: + return chunk + } + data, _ = sjson.SetBytes(data, path+".input_tokens", inputTokens) + data, _ = sjson.SetBytes(data, path+".output_tokens", outputTokens) + data, _ = sjson.SetBytes(data, path+".total_tokens", totalTokens) + data, _ = sjson.SetBytes(data, path+".input_tokens_details.cached_tokens", cachedTokens) + data, _ = sjson.SetBytes(data, path+".output_tokens_details.reasoning_tokens", reasoningTokens) + var buf bytes.Buffer + buf.Write(chunk[:idx]) + buf.WriteString(dataPrefix) + buf.Write(data) + buf.WriteString("\n\n") + return buf.Bytes() +} + +// ValidateResponsesCompactionSummaryStatus ensures the upstream summary response finished with status "completed". +func ValidateResponsesCompactionSummaryStatus(summaryPayload []byte) error { + status := strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "status").String())) + if status == "" { + status = strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "response.status").String())) + } + if status != "completed" { + if status == "" { + status = "missing" + } + return fmt.Errorf("compaction summary response status is %q, want \"completed\"", status) + } + if eventType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "type").String())); eventType == "response.incomplete" || eventType == "response.failed" { + return fmt.Errorf("compaction summary response terminal event is %q", eventType) + } + for _, path := range []string{"incomplete_details", "response.incomplete_details", "error", "response.error"} { + if node := gjson.GetBytes(summaryPayload, path); node.Exists() && node.Type != gjson.Null { + return fmt.Errorf("compaction summary response contains %s", path) + } + } + if output := gjson.GetBytes(summaryPayload, "output"); output.IsArray() { + for _, item := range output.Array() { + itemStatus := strings.ToLower(strings.TrimSpace(item.Get("status").String())) + if itemStatus != "" && itemStatus != "completed" { + return fmt.Errorf("compaction summary output item status is %q, want \"completed\"", itemStatus) + } + } + } + return nil +} + +// BuildResponsesCompactionResponseFromSummary seals summary text from an OpenAI Responses payload into a response.compaction payload. +func BuildResponsesCompactionResponseFromSummary(modelName string, summaryPayload []byte) ([]byte, error) { + if errStatus := ValidateResponsesCompactionSummaryStatus(summaryPayload); errStatus != nil { + return nil, errStatus + } + summaryText, errExtract := ExtractAntigravitySummaryText(summaryPayload) + if errExtract != nil { + return nil, fmt.Errorf("extract summary: %w", errExtract) + } + capsule, errSeal := SealAntigravityCompaction(summaryText, modelName) + if errSeal != nil { + return nil, fmt.Errorf("seal compaction capsule: %w", errSeal) + } + inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens := ExtractResponsesCompactionUsage(summaryPayload) + respPayload := BuildAntigravityCompactionResponse(modelName, capsule, inputTokens, outputTokens, totalTokens) + respPayload, _ = sjson.SetBytes(respPayload, "usage.input_tokens_details.cached_tokens", cachedTokens) + respPayload, _ = sjson.SetBytes(respPayload, "usage.output_tokens_details.reasoning_tokens", reasoningTokens) + return respPayload, nil +} + +// BuildResponsesCompactionStreamChunksFromResponse converts a sealed response.compaction payload into SSE stream chunks. +func BuildResponsesCompactionStreamChunksFromResponse(modelName string, compactionPayload []byte) ([][]byte, error) { + item := gjson.GetBytes(compactionPayload, "output.0") + capsule := item.Get("encrypted_content").String() + if item.Get("type").String() != "compaction" || capsule == "" { + return nil, fmt.Errorf("extract summary: compaction item missing") + } + inputTokens := int(gjson.GetBytes(compactionPayload, "usage.input_tokens").Int()) + outputTokens := int(gjson.GetBytes(compactionPayload, "usage.output_tokens").Int()) + totalTokens := int(gjson.GetBytes(compactionPayload, "usage.total_tokens").Int()) + cachedTokens := int(gjson.GetBytes(compactionPayload, "usage.input_tokens_details.cached_tokens").Int()) + reasoningTokens := int(gjson.GetBytes(compactionPayload, "usage.output_tokens_details.reasoning_tokens").Int()) + chunks := BuildAntigravityCompactionStreamChunks(modelName, capsule, inputTokens, outputTokens, totalTokens) + for i := range chunks { + chunks[i] = PatchResponsesCompactionStreamUsage(chunks[i], inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens) + } + return chunks, nil +} + func buildSSEFrame(event string, data []byte) []byte { var buf bytes.Buffer buf.WriteString("event: ") diff --git a/internal/runtime/executor/helps/antigravity_interactions_state.go b/internal/runtime/executor/helps/antigravity_interactions_state.go new file mode 100644 index 00000000..1c370200 --- /dev/null +++ b/internal/runtime/executor/helps/antigravity_interactions_state.go @@ -0,0 +1,159 @@ +package helps + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/cache" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var antigravityInteractionsSessions = cache.NewInteractionsSessionCache() + +// AntigravityInteractionsState tracks a single request, including streamed steps. +// The cache namespace is computed before continuation rewriting and payload rules. +type AntigravityInteractionsState struct { + key string + id string + environment string + calls []string +} + +func interactionsDigest(value any) string { + encoded, errMarshal := json.Marshal(value) + if errMarshal != nil { + return "" + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]) +} + +// PrepareAntigravityInteractions restores only a matching tool-result suffix. +// Explicit continuation always wins, including an explicitly empty field. +func PrepareAntigravityInteractions(auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, model string, body []byte) (*AntigravityInteractionsState, []byte) { + state := &AntigravityInteractionsState{} + if !strings.HasPrefix(strings.ToLower(model), "antigravity") || auth == nil { + return state, body + } + caller, _ := opts.Metadata[cliproxyexecutor.CallerScopeMetadataKey].(string) + if caller == "" { + caller, _ = req.Metadata[cliproxyexecutor.CallerScopeMetadataKey].(string) + } + if caller == "" { + caller = opts.Headers.Get("Authorization") + } + original := opts.OriginalRequest + if len(original) == 0 { + original = req.Payload + } + identity := "" + if info, ok := session.ExtractSessionInfo(opts.Headers, original, opts.Metadata); ok { + identity = info.SessionID + } + input := gjson.GetBytes(body, "input").Array() + if identity == "" { + lastUser := -1 + for i, step := range input { + if step.Get("type").String() == "user_input" { + lastUser = i + } + } + if lastUser < 0 { + return state, body + } + // Decode JSON objects before hashing, so whitespace and key order do not + // split a conversation. Tool rounds after the last user are excluded. + prefix := make([]json.RawMessage, 0, lastUser+1) + for _, step := range input[:lastUser+1] { + prefix = append(prefix, json.RawMessage(step.Raw)) + } + raw, errMarshal := json.Marshal(map[string]any{"input": prefix, "system_instruction": gjson.GetBytes(body, "system_instruction").Value()}) + if errMarshal != nil { + return state, body + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var canonical any + if errDecode := decoder.Decode(&canonical); errDecode != nil { + return state, body + } + identity = interactionsDigest(canonical) + } + state.key = interactionsDigest([]string{caller, auth.ID, auth.Provider, auth.Attributes["api_key"], auth.Attributes["base_url"], model, identity}) + if gjson.GetBytes(body, "previous_interaction_id").Exists() { + return state, body + } + start := len(input) + var calls []string + for start > 0 && input[start-1].Get("type").String() == "function_result" { + start-- + calls = append(calls, input[start].Get("call_id").String()) + } + callKey := cache.InteractionsCallKey(calls) + if callKey == "" { + return state, body + } + cached, ok := antigravityInteractionsSessions.Get(state.key + ":" + callKey) + if !ok { + return state, body + } + body, _ = sjson.SetBytes(body, "previous_interaction_id", cached.ID) + if !gjson.GetBytes(body, "environment_id").Exists() && cached.Environment != "" { + body, _ = sjson.SetBytes(body, "environment_id", cached.Environment) + } + results := make([]json.RawMessage, 0, len(input)-start) + for _, step := range input[start:] { + results = append(results, json.RawMessage(step.Raw)) + } + body, _ = sjson.SetBytes(body, "input", results) + return state, body +} + +// Observe records only completed requires_action interactions, never failed or +// truncated streams. Stream events may carry steps outside the final snapshot. +func (s *AntigravityInteractionsState) Observe(payload []byte) { + if s == nil || s.key == "" { + return + } + root := gjson.ParseBytes(payload) + event := root.Get("event_type").String() + interaction := root + if event != "" { + interaction = root.Get("interaction") + } + if id := interaction.Get("id").String(); id != "" { + s.id = id + } + if environment := interaction.Get("environment_id").String(); environment != "" { + s.environment = environment + } + if event == "step.start" && root.Get("step.type").String() == "function_call" { + s.calls = append(s.calls, root.Get("step.id").String()) + } + if event != "" && event != "interaction.completed" { + return + } + if interaction.Get("status").String() != "requires_action" || s.id == "" { + return + } + if steps := interaction.Get("steps"); steps.IsArray() { + s.calls = nil + for _, step := range steps.Array() { + if step.Get("type").String() == "function_call" { + s.calls = append(s.calls, step.Get("id").String()) + } + } + } + callKey := cache.InteractionsCallKey(s.calls) + if callKey == "" { + return + } + antigravityInteractionsSessions.Put(s.key+":"+callKey, cache.InteractionsContinuation{ID: s.id, Environment: s.environment}) +} diff --git a/internal/runtime/executor/helps/antigravity_structured_output.go b/internal/runtime/executor/helps/antigravity_structured_output.go new file mode 100644 index 00000000..412432ce --- /dev/null +++ b/internal/runtime/executor/helps/antigravity_structured_output.go @@ -0,0 +1,74 @@ +package helps + +import ( + "regexp" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var claude55Model = regexp.MustCompile(`^claude-(opus|sonnet)-5-5(?:-(high|reasoning))?(?:-[0-9]{8})?(?:\[[^\[\]]+\])?$`) + +// PrepareAntigravityClaudeStructuredOutput replaces native output schemas with +// best-effort prompt instructions for Claude 5.5, which rejects forced tool use. +// Call after sensitive-word obfuscation and before schema cleaning or payload rules. +// The boolean tells the request builder not to restore VALIDATED tool mode. +func PrepareAntigravityClaudeStructuredOutput(model string, payload []byte) ([]byte, bool) { + model = strings.ToLower(thinking.ParseSuffix(strings.TrimSpace(model)).ModelName) + if !claude55Model.MatchString(model) { + return payload, false + } + + containers := []string{"request.generationConfig", "request.generation_config"} + schemaKeys := []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"} + var schema gjson.Result + for _, container := range containers { + for _, key := range schemaKeys { + if candidate := gjson.GetBytes(payload, container+"."+key); candidate.IsObject() { + schema = candidate + break + } + } + if schema.Exists() { + break + } + } + if !schema.Exists() { + return payload, false + } + + // Wrap the untouched schema as a format, not as format settings themselves. + format := gjson.Parse(`{"type":"json_schema","schema":` + schema.Raw + `}`) + instruction := common.BuildClaudeStructuredOutputInstruction(format) + systemPath := "request.systemInstruction" + if !gjson.GetBytes(payload, systemPath).Exists() && gjson.GetBytes(payload, "request.system_instruction").Exists() { + systemPath = "request.system_instruction" + } + updated, errSet := sjson.SetBytes(payload, systemPath+".parts.-1.text", instruction) + if errSet != nil { + return payload, false + } + payload = updated + + // Remove only native output leaves; other config fields and aliases stay put. + for _, container := range containers { + for _, key := range append(schemaKeys, "responseMimeType", "response_mime_type") { + payload, _ = sjson.DeleteBytes(payload, container+"."+key) + } + } + for _, container := range []string{"request.toolConfig", "request.tool_config"} { + for _, key := range []string{"functionCallingConfig", "function_calling_config"} { + path := container + "." + key + switch strings.ToUpper(gjson.GetBytes(payload, path+".mode").String()) { + case "ANY", "VALIDATED": + payload, _ = sjson.SetBytes(payload, path+".mode", "AUTO") + payload, _ = sjson.DeleteBytes(payload, path+".allowedFunctionNames") + payload, _ = sjson.DeleteBytes(payload, path+".allowed_function_names") + } + } + } + return payload, true +} diff --git a/internal/runtime/executor/helps/gemini_content_turns.go b/internal/runtime/executor/helps/gemini_content_turns.go index 29ae9fe7..1aa2bedc 100644 --- a/internal/runtime/executor/helps/gemini_content_turns.go +++ b/internal/runtime/executor/helps/gemini_content_turns.go @@ -51,10 +51,30 @@ func contentHasFunctionResponse(content gjson.Result) bool { return false } +// contentHasFunctionResponseMedia reports whether any functionResponse in the +// content carries nested media parts (inlineData/fileData). +func contentHasFunctionResponseMedia(content gjson.Result) bool { + parts := content.Get("parts") + if !parts.IsArray() { + return false + } + for _, part := range parts.Array() { + for _, nested := range part.Get("functionResponse.parts").Array() { + if nested.Get("inlineData").Exists() || nested.Get("inline_data").Exists() || + nested.Get("fileData").Exists() || nested.Get("file_data").Exists() { + return true + } + } + } + return false +} + // EnsureGeminiTrailingUserContent ensures that the contents array at the given path // ends with a user turn when sending to Gemini/Antigravity upstreams. // If the final turn contains a functionResponse, it is preserved without appending // an empty user turn because upstream expects the model to generate the response. +// A model-role functionResponse turn carrying nested media is the exception: +// Antigravity rejects it as the final turn with 400 INVALID_ARGUMENT (#6524). func EnsureGeminiTrailingUserContent(payload []byte, path string) []byte { contents := util.GetGJSONBytesNoCopy(payload, path) if !contents.IsArray() { @@ -66,7 +86,10 @@ func EnsureGeminiTrailingUserContent(payload []byte, path string) []byte { } lastContent := contentArray[len(contentArray)-1] lastRole := lastContent.Get("role").String() - if (lastRole != "model" && lastRole != "assistant") || contentHasFunctionResponse(lastContent) { + if lastRole != "model" && lastRole != "assistant" { + return payload + } + if contentHasFunctionResponse(lastContent) && !contentHasFunctionResponseMedia(lastContent) { return payload } @@ -83,6 +106,53 @@ func EnsureGeminiTrailingUserContent(payload []byte, path string) []byte { return out } +// DropGeminiTrailingThoughtOnlyContent removes trailing model turns made only of +// thought parts, keeping at least one turn. Such a tail comes from a replayed +// trailing reasoning item; dropping it avoids relying only on the synthetic +// empty user turn, which did not prevent Antigravity (#6530) or Vertex (#6630) +// from rejecting the request as ending with a model turn. +func DropGeminiTrailingThoughtOnlyContent(payload []byte, path string) []byte { + contents := util.GetGJSONBytesNoCopy(payload, path) + if !contents.IsArray() { + return payload + } + contentArray := contents.Array() + keep := len(contentArray) + for keep > 1 && contentIsThoughtOnlyModelTurn(contentArray[keep-1]) { + keep-- + } + if keep == len(contentArray) { + return payload + } + + contentItems := make([][]byte, 0, keep) + for _, content := range contentArray[:keep] { + contentItems = append(contentItems, []byte(content.Raw)) + } + out, errSet := sjson.SetRawBytes(payload, path, translatorcommon.JoinRawArray(contentItems)) + if errSet != nil { + return payload + } + return out +} + +func contentIsThoughtOnlyModelTurn(content gjson.Result) bool { + role := content.Get("role").String() + if role != "model" && role != "assistant" { + return false + } + parts := content.Get("parts").Array() + if len(parts) == 0 { + return false + } + for _, part := range parts { + if !part.Get("thought").Bool() || part.Get("functionCall").Exists() || part.Get("functionResponse").Exists() { + return false + } + } + return true +} + // EnsureGeminiBoundaryUserContent ensures that the contents array at the given path // both starts and ends with a user turn when sending to Gemini/Antigravity upstreams. func EnsureGeminiBoundaryUserContent(payload []byte, path string) []byte { diff --git a/internal/runtime/executor/helps/gemini_interactions.go b/internal/runtime/executor/helps/gemini_interactions.go new file mode 100644 index 00000000..9218ebee --- /dev/null +++ b/internal/runtime/executor/helps/gemini_interactions.go @@ -0,0 +1,243 @@ +package helps + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + // GeminiInteractionsAPIRevision is the Api-Revision header required for the Interactions API. + GeminiInteractionsAPIRevision = "2026-05-20" + // StreamScannerBuffer sets the scanner buffer capacity for streaming response chunks (50MB). + StreamScannerBuffer = 52_428_800 +) + +// SanitizeGeminiInteractionsUnsupportedInputIDs aligns input step IDs with the +// official Gemini Interactions API schema: +// - `function_call` (FunctionCallStep) requires `id` and rejects `call_id` +// - `function_result` (FunctionResultStep) requires `call_id` and rejects `id` +// - other steps and content parts do not support `id` +func SanitizeGeminiInteractionsUnsupportedInputIDs(body []byte) []byte { + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body + } + items := input.Array() + kept := make([]string, 0, len(items)) + changed := false + for _, item := range items { + rawItem := item.Raw + stepType := item.Get("type").String() + if stepType == "thought" { + sig := strings.TrimSpace(item.Get("signature").String()) + if sig == "" { + sig = strings.TrimSpace(item.Get("thought_signature").String()) + } + if sig == "" { + sig = strings.TrimSpace(item.Get("thoughtSignature").String()) + } + normalizedSig, ok := signature.CompatibleSignatureForProviderBlock( + signature.SignatureProviderGemini, + sig, + signature.SignatureBlockKindGeminiModelPart, + ) + if !ok || normalizedSig == "" || signature.IsGeminiThoughtSignatureBypass(signature.SignaturePayloadWithoutProviderPrefix(normalizedSig)) { + // Official Gemini Interactions API rejects thought steps without a + // valid Gemini signature (or with foreign/corrupt base64 signatures + // or "content" arrays). + changed = true + continue + } + if normalizedSig != item.Get("signature").String() { + rawItem, _ = sjson.Set(rawItem, "signature", normalizedSig) + changed = true + } + for _, unsupportedField := range []string{"id", "content", "text", "thought_signature", "thoughtSignature"} { + if gjson.Get(rawItem, unsupportedField).Exists() { + rawItem, _ = sjson.Delete(rawItem, unsupportedField) + changed = true + } + } + kept = append(kept, rawItem) + continue + } + if stepType == "function_call" { + if !item.Get("id").Exists() && item.Get("call_id").Exists() { + rawItem, _ = sjson.Set(rawItem, "id", item.Get("call_id").String()) + changed = true + } + if item.Get("call_id").Exists() { + rawItem, _ = sjson.Delete(rawItem, "call_id") + changed = true + } + } else if item.Get("id").Exists() { + rawItem, _ = sjson.Delete(rawItem, "id") + changed = true + } + content := gjson.Get(rawItem, "content") + if content.IsArray() { + for j, part := range content.Array() { + if part.Get("id").Exists() { + rawItem, _ = sjson.Delete(rawItem, fmt.Sprintf("content.%d.id", j)) + changed = true + } + } + } + kept = append(kept, rawItem) + } + if !changed { + return body + } + var b strings.Builder + b.WriteByte('[') + for i, raw := range kept { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(raw) + } + b.WriteByte(']') + updated, err := sjson.SetRawBytes(body, "input", []byte(b.String())) + if err != nil { + return body + } + return updated +} + +// TranslateGeminiInteractionsRequestBody translates a request payload to FormatInteractions. +func TranslateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) ([]byte, error) { + if opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions { + return bytes.Clone(payload), nil + } + return TranslateRequestReturningError(ctx, opts.Headers, cfg, opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream, isCompat) +} + +// TranslateGeminiInteractionsRequestPair translates the working payload and the +// payload-config baseline. Identical inputs are translated once, including plugin +// hooks. The baseline is captured before model and thinking mutations, and the +// working buffer is a separate copy so those mutations cannot change it. +func TranslateGeminiInteractionsRequestPair(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) (original, working []byte, err error) { + source := geminiInteractionsPayloadConfigInput(opts, payload) + if geminiInteractionsSameByteSlice(payload, source) { + original, err = TranslateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat) + return original, bytes.Clone(original), err + } + working, err = TranslateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat) + original, _ = geminiInteractionsPayloadConfigSource(ctx, cfg, model, payload, opts, stream, isCompat) + return original, working, err +} + +func geminiInteractionsPayloadConfigSource(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) ([]byte, error) { + return TranslateGeminiInteractionsRequestBody(ctx, cfg, model, geminiInteractionsPayloadConfigInput(opts, payload), opts, stream, isCompat) +} + +func geminiInteractionsPayloadConfigInput(opts cliproxyexecutor.Options, payload []byte) []byte { + if len(opts.OriginalRequest) == 0 { + return payload + } + return opts.OriginalRequest +} + +func geminiInteractionsSameByteSlice(a, b []byte) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + return &a[0] == &b[0] +} + +// ApplyGeminiInteractionsThinking applies thinking/reasoning configuration to the interactions payload. +func ApplyGeminiInteractionsThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) { + fromFormat := opts.SourceFormat.String() + if strings.TrimSpace(fromFormat) == "" { + fromFormat = sdktranslator.FormatInteractions.String() + } + return ApplyRequestThinking(body, req, opts, fromFormat, sdktranslator.FormatInteractions.String(), "gemini") +} + +// ApplyGeminiInteractionsRevisionHeader sets the default Api-Revision header on outgoing HTTP requests. +func ApplyGeminiInteractionsRevisionHeader(req *http.Request) { + if req == nil { + return + } + if req.Header.Get("Api-Revision") == "" { + req.Header.Set("Api-Revision", GeminiInteractionsAPIRevision) + } +} + +// ApplyGeminiInteractionsRequestHeaders forwards any incoming Api-Revision header to upstream requests. +func ApplyGeminiInteractionsRequestHeaders(req *http.Request, headers http.Header) { + if req == nil || headers == nil || req.Header.Get("Api-Revision") != "" { + return + } + if revision := headers.Get("Api-Revision"); revision != "" { + req.Header.Set("Api-Revision", revision) + } +} + +// GeminiInteractionsSSEPayload extracts SSE data payload from raw SSE frames. +func GeminiInteractionsSSEPayload(frame []byte) []byte { + trimmed := bytes.TrimSpace(frame) + if len(trimmed) == 0 { + return nil + } + if bytes.HasPrefix(trimmed, []byte("{")) { + return trimmed + } + lines := bytes.Split(frame, []byte{'\n'}) + var payload []byte + for _, line := range lines { + line = bytes.TrimRight(line, "\r") + if !bytes.HasPrefix(bytes.TrimSpace(line), []byte("data:")) { + continue + } + data := bytes.TrimSpace(line[bytes.Index(line, []byte("data:"))+len("data:"):]) + if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + continue + } + if len(payload) > 0 { + payload = append(payload, '\n') + } + payload = append(payload, data...) + } + if len(payload) == 0 { + return nil + } + return payload +} + +// GeminiInteractionsSSEDone reports whether the SSE frame signals stream completion. +func GeminiInteractionsSSEDone(frame []byte) bool { + trimmed := bytes.TrimSpace(frame) + if bytes.Equal(trimmed, []byte("[DONE]")) { + return true + } + lines := bytes.Split(frame, []byte{'\n'}) + sawDoneEvent := false + for _, line := range lines { + line = bytes.TrimSpace(bytes.TrimRight(line, "\r")) + if bytes.EqualFold(line, []byte("event: done")) { + sawDoneEvent = true + continue + } + if bytes.HasPrefix(line, []byte("data:")) { + data := bytes.TrimSpace(line[len("data:"):]) + if bytes.Equal(data, []byte("[DONE]")) { + return true + } + } + } + return sawDoneEvent +} diff --git a/internal/runtime/executor/helps/gemini_stream_terminal.go b/internal/runtime/executor/helps/gemini_stream_terminal.go new file mode 100644 index 00000000..b5e16b28 --- /dev/null +++ b/internal/runtime/executor/helps/gemini_stream_terminal.go @@ -0,0 +1,37 @@ +package helps + +import ( + "bytes" + "strings" + + "github.com/tidwall/gjson" +) + +// IsGeminiTerminalStreamChunk recognizes terminal output from Gemini stream translators. +func IsGeminiTerminalStreamChunk(chunk []byte) bool { + for _, line := range bytes.Split(chunk, []byte("\n")) { + trimmed := bytes.TrimSpace(line) + if bytes.Equal(trimmed, []byte("data: [DONE]")) || bytes.Equal(trimmed, []byte("[DONE]")) { + return true + } + payload := JSONPayload(line) + if len(payload) == 0 { + continue + } + eventType := gjson.GetBytes(payload, "type").String() + if eventType == "response.completed" || eventType == "response.incomplete" || eventType == "response.done" || eventType == "message_stop" { + return true + } + for _, path := range []string{ + "choices.0.finish_reason", + "response.choices.0.finish_reason", + "candidates.0.finishReason", + "response.candidates.0.finishReason", + } { + if finishReason := gjson.GetBytes(payload, path); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" { + return true + } + } + } + return false +} diff --git a/internal/runtime/executor/helps/vertex_openapi.go b/internal/runtime/executor/helps/vertex_openapi.go new file mode 100644 index 00000000..465025d6 --- /dev/null +++ b/internal/runtime/executor/helps/vertex_openapi.go @@ -0,0 +1,610 @@ +package helps + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/tidwall/gjson" +) + +// IsVertexOpenAPIModel reports whether the given model identifier targets a +// non-Google Vertex partner or open model (MaaS) served by endpoints/openapi. +func IsVertexOpenAPIModel(model string) bool { + baseModel := strings.TrimSpace(thinking.ParseSuffix(model).ModelName) + if baseModel == "" { + return false + } + if publisher, modelID, ok := cutVertexPublisherResource(baseModel); ok { + return !strings.EqualFold(publisher, "google") && !strings.Contains(modelID, "/") + } + publisher, modelID, ok := strings.Cut(baseModel, "/") + publisher = strings.TrimSpace(publisher) + modelID = strings.TrimSpace(modelID) + if !ok || publisher == "" || modelID == "" || strings.Contains(modelID, "/") { + return false + } + if strings.EqualFold(publisher, "google") || strings.EqualFold(publisher, "models") || strings.EqualFold(publisher, "publishers") { + return false + } + return true +} + +// NormalizeVertexOpenAPIModel normalizes a Vertex model identifier into the +// "/" form expected by Vertex's endpoints/openapi route while +// preserving any thinking suffix. +func NormalizeVertexOpenAPIModel(model string) string { + trimmed := strings.TrimSpace(model) + if trimmed == "" { + return "" + } + parsed := thinking.ParseSuffix(trimmed) + baseModel := strings.TrimSpace(parsed.ModelName) + if baseModel == "" { + baseModel = trimmed + } + normalizedBase := normalizeVertexOpenAPIBaseModel(baseModel) + if parsed.HasSuffix { + return fmt.Sprintf("%s(%s)", normalizedBase, parsed.RawSuffix) + } + return normalizedBase +} + +func normalizeVertexOpenAPIBaseModel(baseModel string) string { + if publisher, modelID, ok := cutVertexPublisherResource(baseModel); ok { + return strings.ToLower(publisher) + "/" + modelID + } + lower := strings.ToLower(baseModel) + if strings.HasPrefix(lower, "models/") { + modelID := strings.TrimSpace(baseModel[len("models/"):]) + if modelID != "" { + return "google/" + modelID + } + } + if publisher, modelID, ok := strings.Cut(baseModel, "/"); ok { + publisher = strings.TrimSpace(publisher) + modelID = strings.TrimSpace(modelID) + if publisher != "" && modelID != "" { + return publisher + "/" + modelID + } + } + return "google/" + baseModel +} + +func cutVertexPublisherResource(baseModel string) (publisher, modelID string, ok bool) { + lower := strings.ToLower(baseModel) + if !strings.HasPrefix(lower, "publishers/") { + return "", "", false + } + rest := strings.TrimSpace(baseModel[len("publishers/"):]) + pub, tail, found := strings.Cut(rest, "/") + pub = strings.TrimSpace(pub) + tail = strings.TrimSpace(tail) + if !found || pub == "" || tail == "" { + return "", "", false + } + lowerTail := strings.ToLower(tail) + if !strings.HasPrefix(lowerTail, "models/") { + return "", "", false + } + id := strings.TrimSpace(tail[len("models/"):]) + if id == "" { + return "", "", false + } + return pub, id, true +} + +// ShouldExecuteVertexOpenAPI reports whether the request should be routed +// through Vertex AI's OpenAI-compatible endpoints/openapi route. +func ShouldExecuteVertexOpenAPI(auth *cliproxyauth.Auth, model string) bool { + return ShouldExecuteVertexOpenAPIForRequest(nil, auth, model, cliproxyexecutor.Options{}) +} + +// ShouldExecuteVertexOpenAPIForRequest resolves model, credential, and automatic +// endpoint selection in that order, preserving alias-specific overrides. +func ShouldExecuteVertexOpenAPIForRequest(cfg *config.Config, auth *cliproxyauth.Auth, model string, opts cliproxyexecutor.Options) bool { + model = strings.TrimSpace(model) + baseModel := strings.TrimSpace(thinking.ParseSuffix(model).ModelName) + if strings.Contains(strings.ToLower(baseModel), "imagen") { + return false + } + requestedModel := PayloadRequestedModel(opts, "") + if requestedModel == "" { + requestedModel = strings.TrimSpace(gjson.GetBytes(opts.OriginalRequest, "model").String()) + } + if requestedModel == "" && len(opts.Metadata) > 0 { + if data, errMarshal := json.Marshal(opts.Metadata); errMarshal == nil { + requestedModel = strings.TrimSpace(gjson.GetBytes(data, "model").String()) + } + } + requestedModel = stripVertexAuthPrefix(requestedModel, auth) + entry := ResolveVertexCompatConfig(cfg, auth) + if entry != nil { + rules := make([]config.OAuthModelAlias, 0, len(entry.Models)) + for _, configured := range entry.Models { + rules = append(rules, config.OAuthModelAlias{Name: configured.Name, Alias: configured.Alias, OpenAPI: configured.OpenAPI}) + } + if setting := matchVertexModelOpenAPI(rules, model, requestedModel); setting != nil { + return *setting + } + } + isOAuth := isVertexOAuthCredential(auth) + var oauthAndAuthRules []config.OAuthModelAlias + if auth != nil { + for _, key := range []string{"model_aliases", "model-aliases", "models"} { + if data, errMarshal := json.Marshal(auth.Metadata[key]); errMarshal == nil { + oauthAndAuthRules = append(oauthAndAuthRules, vertexModelOpenAPIRules(data)...) + } + } + oauthAndAuthRules = append(oauthAndAuthRules, vertexModelOpenAPIRules([]byte(auth.Attributes["vertex_model_openapi"]))...) + if isOAuth { + oauthAndAuthRules = append(oauthAndAuthRules, cliproxyauth.OAuthModelAliasesFromAttributes(auth.Attributes)...) + } + } + if cfg != nil && isOAuth { + oauthAndAuthRules = append(oauthAndAuthRules, cfg.OAuthModelAlias["vertex"]...) + } + if setting := matchVertexModelOpenAPI(deduplicateVertexModelOpenAPIRules(oauthAndAuthRules), model, requestedModel); setting != nil { + return *setting + } + if cfg != nil && isOAuth { + settingRules := make([]config.OAuthModelAlias, 0, len(cfg.OAuthSettings["vertex"])) + for _, configured := range cfg.OAuthSettings["vertex"] { + settingRules = append(settingRules, config.OAuthModelAlias{Name: configured.Name, Alias: configured.Alias, OpenAPI: configured.OpenAPI}) + } + if setting := matchVertexModelOpenAPI(settingRules, model, requestedModel); setting != nil { + return *setting + } + } + if setting, hasExplicit := vertexOpenAPIExplicitSetting(auth); hasExplicit { + return setting + } + if entry != nil && entry.OpenAPI != nil { + return *entry.OpenAPI + } + return IsVertexOpenAPIModel(model) +} + +func stripVertexAuthPrefix(model string, auth *cliproxyauth.Auth) string { + model = strings.TrimSpace(model) + if auth == nil || model == "" { + return model + } + prefix := strings.Trim(strings.TrimSpace(auth.Prefix), "/") + if prefix == "" && auth.Attributes != nil { + prefix = strings.Trim(strings.TrimSpace(auth.Attributes["prefix"]), "/") + } + if prefix == "" { + return model + } + needle := prefix + "/" + if len(model) > len(needle) && strings.EqualFold(model[:len(needle)], needle) { + return strings.TrimSpace(model[len(needle):]) + } + return model +} + +func isVertexOAuthCredential(auth *cliproxyauth.Auth) bool { + if auth == nil { + return true + } + if auth.Metadata != nil { + if sa, ok := auth.Metadata["service_account"].(map[string]any); ok && len(sa) > 0 { + return true + } + } + return cliproxyauth.OAuthModelAliasChannel("vertex", auth.AuthKind()) != "" +} + +func vertexModelOpenAPIRules(data []byte) []config.OAuthModelAlias { + var rules []config.OAuthModelAlias + for _, item := range gjson.ParseBytes(data).Array() { + var rule config.OAuthModelAlias + if errUnmarshal := json.Unmarshal([]byte(item.Raw), &rule); errUnmarshal == nil && (strings.TrimSpace(rule.Name) != "" || strings.TrimSpace(rule.Alias) != "") { + rules = append(rules, rule) + } + } + return rules +} + +func deduplicateVertexModelOpenAPIRules(rules []config.OAuthModelAlias) []config.OAuthModelAlias { + if len(rules) == 0 { + return nil + } + out := make([]config.OAuthModelAlias, 0, len(rules)) + seenAlias := make(map[string]struct{}, len(rules)) + seenNameOnly := make(map[string]struct{}, len(rules)) + for _, rule := range rules { + name := strings.TrimSpace(rule.Name) + alias := strings.TrimSpace(rule.Alias) + if name == "" && alias == "" { + continue + } + if alias != "" && !strings.EqualFold(name, alias) { + aliasKey := strings.ToLower(alias) + if _, exists := seenAlias[aliasKey]; exists { + continue + } + seenAlias[aliasKey] = struct{}{} + } else { + if rule.OpenAPI == nil { + continue + } + nameKey := strings.ToLower(name) + if _, exists := seenNameOnly[nameKey]; exists { + continue + } + seenNameOnly[nameKey] = struct{}{} + } + out = append(out, rule) + } + return out +} + +func matchVertexModelOpenAPI(rules []config.OAuthModelAlias, model, requestedModel string) *bool { + base := func(value string) string { + return strings.TrimSpace(thinking.ParseSuffix(strings.TrimSpace(value)).ModelName) + } + exactMatch := func(value, rule string) bool { + value, rule = strings.TrimSpace(value), strings.TrimSpace(rule) + return value != "" && rule != "" && strings.EqualFold(value, rule) + } + baseMatch := func(value, rule string) bool { + value, rule = strings.TrimSpace(value), strings.TrimSpace(rule) + if value == "" || rule == "" || thinking.ParseSuffix(rule).HasSuffix { + return false + } + return strings.EqualFold(base(value), base(rule)) + } + anyMatch := func(value, rule string) bool { + return exactMatch(value, rule) || baseMatch(value, rule) + } + aliasTarget := requestedModel + if aliasTarget == "" { + aliasTarget = model + } + aliasConsistentWithModel := func(rule config.OAuthModelAlias) bool { + if strings.TrimSpace(rule.Name) == "" || strings.TrimSpace(model) == "" || anyMatch(model, rule.Alias) { + return true + } + return anyMatch(model, rule.Name) + } + for _, rule := range rules { + if exactMatch(aliasTarget, rule.Alias) && aliasConsistentWithModel(rule) { + return rule.OpenAPI + } + } + for _, rule := range rules { + if baseMatch(aliasTarget, rule.Alias) && aliasConsistentWithModel(rule) { + return rule.OpenAPI + } + } + canMatchName := func(rule config.OAuthModelAlias) bool { + return strings.TrimSpace(rule.Alias) == "" || strings.EqualFold(strings.TrimSpace(rule.Alias), strings.TrimSpace(rule.Name)) || + requestedModel == "" || anyMatch(requestedModel, rule.Alias) || anyMatch(requestedModel, rule.Name) + } + for _, rule := range rules { + if rule.OpenAPI != nil && (exactMatch(model, rule.Name) || exactMatch(requestedModel, rule.Name)) && canMatchName(rule) { + return rule.OpenAPI + } + } + for _, rule := range rules { + if rule.OpenAPI != nil && (baseMatch(model, rule.Name) || baseMatch(requestedModel, rule.Name)) && canMatchName(rule) { + return rule.OpenAPI + } + } + return nil +} + +// ResolveVertexCompatConfig finds the API-key configuration belonging to auth. +func ResolveVertexCompatConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.VertexCompatKey { + if cfg == nil || auth == nil { + return nil + } + attrKey := strings.TrimSpace(auth.Attributes["api_key"]) + attrBase := strings.TrimSpace(auth.Attributes["base_url"]) + matches := func(entry config.VertexCompatKey) bool { + key, base := strings.TrimSpace(entry.APIKey), strings.TrimSpace(entry.BaseURL) + if attrKey != "" && attrBase != "" { + return strings.EqualFold(key, attrKey) && strings.EqualFold(base, attrBase) + } + if attrKey != "" { + return strings.EqualFold(key, attrKey) && (base == "" || strings.EqualFold(base, attrBase)) + } + return attrBase != "" && strings.EqualFold(base, attrBase) + } + if index, errIndex := strconv.Atoi(auth.Attributes["config_index"]); errIndex == nil && index >= 0 && index < len(cfg.VertexCompatAPIKey) && matches(cfg.VertexCompatAPIKey[index]) { + return &cfg.VertexCompatAPIKey[index] + } + for i := range cfg.VertexCompatAPIKey { + entry := &cfg.VertexCompatAPIKey[i] + if matches(*entry) && strings.EqualFold(strings.TrimSpace(entry.Prefix), strings.TrimSpace(auth.Prefix)) && strings.EqualFold(strings.TrimSpace(entry.ProxyURL), strings.TrimSpace(auth.ProxyURL)) { + return entry + } + } + for i := range cfg.VertexCompatAPIKey { + if matches(cfg.VertexCompatAPIKey[i]) { + return &cfg.VertexCompatAPIKey[i] + } + } + if attrKey != "" { + for i := range cfg.VertexCompatAPIKey { + if strings.EqualFold(strings.TrimSpace(cfg.VertexCompatAPIKey[i].APIKey), attrKey) { + return &cfg.VertexCompatAPIKey[i] + } + } + } + return nil +} + +func vertexOpenAPIExplicitSetting(auth *cliproxyauth.Auth) (bool, bool) { + if auth == nil { + return false, false + } + if auth.Attributes != nil { + if v, ok := parseVertexOpenAPIBool(auth.Attributes["openapi"]); ok { + return v, true + } + } + if auth.Metadata != nil { + if v, ok := parseVertexOpenAPIBool(auth.Metadata["openapi"]); ok { + return v, true + } + } + return false, false +} + +func parseVertexOpenAPIBool(raw any) (bool, bool) { + switch v := raw.(type) { + case bool: + return v, true + case string: + trimmed := strings.ToLower(strings.TrimSpace(v)) + switch trimmed { + case "true", "1": + return true, true + case "false", "0": + return false, true + } + } + return false, false +} + +// IsGoogleVertexHost reports whether baseURL is empty or points to Google's +// official aiplatform.googleapis.com or Regional Endpoint (REP) domain. +func IsGoogleVertexHost(baseURL string) bool { + trimmed := strings.TrimSpace(baseURL) + if trimmed == "" { + return true + } + parsed, errParse := url.Parse(trimmed) + if (errParse != nil || parsed.Host == "") && !strings.Contains(trimmed, "://") { + parsed, errParse = url.Parse("https://" + strings.TrimLeft(trimmed, "/")) + } + if errParse != nil || parsed.Host == "" { + lower := strings.ToLower(trimmed) + return strings.Contains(lower, "aiplatform.googleapis.com") + } + host := strings.ToLower(parsed.Hostname()) + return host == "aiplatform.googleapis.com" || strings.HasSuffix(host, "-aiplatform.googleapis.com") || strings.HasSuffix(host, ".aiplatform.googleapis.com") || isGoogleVertexREPHost(host) +} + +func isGoogleVertexREPHost(host string) bool { + if !strings.HasPrefix(host, "aiplatform.") || !strings.HasSuffix(host, ".rep.googleapis.com") { + return false + } + loc := strings.TrimSuffix(strings.TrimPrefix(host, "aiplatform."), ".rep.googleapis.com") + return loc != "" && !strings.Contains(loc, ".") +} + +// VertexExplicitOpenAPILocation returns an explicitly configured OpenAPI location +// from auth attributes/metadata or the matching VertexCompatKey config, or "" if unset. +func VertexExplicitOpenAPILocation(cfg *config.Config, auth *cliproxyauth.Auth) string { + if auth != nil { + if auth.Attributes != nil { + for _, key := range []string{"openapi_location", "openapi-location"} { + if v := strings.TrimSpace(auth.Attributes[key]); v != "" { + return v + } + } + } + if auth.Metadata != nil { + for _, key := range []string{"openapi_location", "openapi-location"} { + if v, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + } + } + if entry := ResolveVertexCompatConfig(cfg, auth); entry != nil && strings.TrimSpace(entry.OpenAPILocation) != "" { + return strings.TrimSpace(entry.OpenAPILocation) + } + return "" +} + +// VertexOpenAPILocation returns the target Vertex location for OpenAPI requests, +// preferring an explicit openapi_location override on the credential. +func VertexOpenAPILocation(auth *cliproxyauth.Auth, defaultLocation string) string { + if explicit := VertexExplicitOpenAPILocation(nil, auth); explicit != "" { + return explicit + } + if loc := strings.TrimSpace(defaultLocation); loc != "" { + return loc + } + if auth != nil { + if auth.Attributes != nil { + if v := strings.TrimSpace(auth.Attributes["location"]); v != "" { + return v + } + } + if auth.Metadata != nil { + if v, ok := auth.Metadata["location"].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + } + return "global" +} + +// VertexAuthBaseURL extracts an optional custom base_url from a Vertex credential. +func VertexAuthBaseURL(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + if auth.Attributes != nil { + if v := strings.TrimSpace(auth.Attributes["base_url"]); v != "" { + return v + } + } + if auth.Metadata != nil { + for _, key := range []string{"base_url", "base-url"} { + if v, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + } + return "" +} + +// VertexAuthProjectID extracts an optional project_id from a Vertex credential. +func VertexAuthProjectID(auth *cliproxyauth.Auth) string { + if auth == nil || auth.Metadata == nil { + return "" + } + for _, key := range []string{"project_id", "project"} { + if v, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +// VertexOpenAPIHostBaseURL returns the Vertex regional, multi-region REP, or global host URL. +func VertexOpenAPIHostBaseURL(location string) string { + loc := strings.TrimSpace(location) + if loc == "" || strings.EqualFold(loc, "global") { + return "https://aiplatform.googleapis.com" + } else if strings.EqualFold(loc, "us") || strings.EqualFold(loc, "eu") { + return fmt.Sprintf("https://aiplatform.%s.rep.googleapis.com", strings.ToLower(loc)) + } + return fmt.Sprintf("https://%s-aiplatform.googleapis.com", loc) +} + +// VertexServiceAccountOpenAPIBaseURL builds the OpenAI-compatible base URL for a +// Vertex service-account credential (.../v1/projects/{project}/locations/{location}/endpoints/openapi). +func VertexServiceAccountOpenAPIBaseURL(baseURL, version, projectID, location string) string { + explicitLoc := strings.TrimSpace(location) + loc := explicitLoc + if loc == "" { + loc = "global" + } + ver := strings.TrimSpace(version) + if ver == "" { + ver = "v1" + } + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if base == "" { + base = VertexOpenAPIHostBaseURL(loc) + } + if strings.HasSuffix(base, "/endpoints/openapi") { + return rewriteVertexOpenAPILocation(strings.TrimSuffix(base, "/endpoints/openapi"), explicitLoc) + "/endpoints/openapi" + } + if idx := strings.Index(base, "/publishers/"); idx >= 0 { + return rewriteVertexOpenAPILocation(base[:idx], explicitLoc) + "/endpoints/openapi" + } + if strings.Contains(base, "/projects/") && strings.Contains(base, "/locations/") { + return rewriteVertexOpenAPILocation(base, explicitLoc) + "/endpoints/openapi" + } + if !vertexOpenAPIHasVersionSegment(base) { + base = fmt.Sprintf("%s/%s", base, ver) + } + built := fmt.Sprintf("%s/projects/%s/locations/%s/endpoints/openapi", base, strings.TrimSpace(projectID), loc) + return rewriteVertexOpenAPILocation(built, explicitLoc) +} + +// VertexAPIKeyOpenAPIBaseURL builds the OpenAI-compatible base URL for a third-party +// Vertex-compatible API-key credential. +func VertexAPIKeyOpenAPIBaseURL(baseURL, version, projectID, location string) string { + ver := strings.TrimSpace(version) + if ver == "" { + ver = "v1" + } + loc := strings.TrimSpace(location) + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if base == "" { + return "" + } + if strings.HasSuffix(base, "/endpoints/openapi") { + return rewriteVertexOpenAPILocation(strings.TrimSuffix(base, "/endpoints/openapi"), loc) + "/endpoints/openapi" + } + if idx := strings.Index(base, "/publishers/"); idx >= 0 { + return rewriteVertexOpenAPILocation(base[:idx], loc) + "/endpoints/openapi" + } + if strings.Contains(base, "/projects/") && strings.Contains(base, "/locations/") { + return rewriteVertexOpenAPILocation(base, loc) + "/endpoints/openapi" + } + if strings.TrimSpace(projectID) != "" { + return VertexServiceAccountOpenAPIBaseURL(base, ver, projectID, loc) + } + if !vertexOpenAPIHasVersionSegment(base) { + base = fmt.Sprintf("%s/%s", base, ver) + } + return rewriteVertexOpenAPILocation(base, loc) + "/endpoints/openapi" +} + +func rewriteVertexOpenAPILocation(base, location string) string { + loc := strings.TrimSpace(location) + if loc == "" { + return base + } + rewritten := base + const marker = "/locations/" + if idx := strings.Index(rewritten, marker); idx >= 0 { + segStart := idx + len(marker) + segEnd := len(rewritten) + if slashIdx := strings.IndexByte(rewritten[segStart:], '/'); slashIdx >= 0 { + segEnd = segStart + slashIdx + } + rewritten = rewritten[:segStart] + loc + rewritten[segEnd:] + } + if parsed, errParse := url.Parse(rewritten); errParse == nil && IsGoogleVertexHost(rewritten) && parsed.Host != "" { + targetHostURL, errHost := url.Parse(VertexOpenAPIHostBaseURL(loc)) + if errHost == nil && targetHostURL.Host != "" { + parsed.Scheme = targetHostURL.Scheme + parsed.Host = targetHostURL.Host + return parsed.String() + } + } + return rewritten +} + +func vertexOpenAPIHasVersionSegment(base string) bool { + if base == "" { + return false + } + if strings.Contains(base, "/v1/") || strings.Contains(base, "/v1beta1/") { + return true + } + seg := base[strings.LastIndexByte(base, '/')+1:] + if len(seg) < 2 || seg[0] != 'v' { + return false + } + hasDigit := false + for i := 1; i < len(seg); i++ { + c := seg[i] + switch { + case c >= '0' && c <= '9': + hasDigit = true + case c == '_' || c == '-' || (c >= 'a' && c <= 'z'): + default: + return false + } + } + return hasDigit +} diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index c213d41f..6a4a2345 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/misc" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" @@ -34,8 +35,16 @@ const ( openAICompatImagesEditsPath = "/images/edits" openAICompatDefaultImageEndpoint = openAICompatImagesGenerationsPath openAICompatMultipartMemory int64 = 32 << 20 + openAICompatDefaultUserAgent = "cli-proxy-openai-compat" ) +func ensureOpenAICompatUserAgent(r *http.Request, clientHeaders ...http.Header) { + if r == nil { + return + } + misc.EnsureHeader(r.Header, util.ResolveClientHeaders(r, clientHeaders...), "User-Agent", openAICompatDefaultUserAgent) +} + // OpenAICompatExecutor implements a stateless executor for OpenAI-compatible providers. // It performs request/response translation and executes against the provider base URL // using per-auth credentials (API key) and per-auth HTTP transport (proxy) from context. @@ -61,6 +70,7 @@ func (e *OpenAICompatExecutor) PrepareRequest(req *http.Request, auth *cliproxya if strings.TrimSpace(apiKey) != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } + ensureOpenAICompatUserAgent(req) var attrs map[string]string if auth != nil { attrs = auth.Attributes @@ -90,13 +100,19 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A if endpointPath := openAICompatImageEndpointPath(opts); endpointPath != "" { return e.executeImages(ctx, auth, req, opts, endpointPath) } + baseURL, apiKey := e.resolveCredentials(auth) + compat := e.resolveCompatConfig(auth, req) + return e.executeWithCredentials(ctx, auth, req, opts, baseURL, apiKey, compat) +} + +func (e *OpenAICompatExecutor) executeWithCredentials(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (resp cliproxyexecutor.Response, err error) { + ctx = helps.EnsureSessionContext(ctx, opts, req.Payload) baseModel := thinking.ParseSuffix(req.Model).ModelName reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err) - baseURL, apiKey := e.resolveCredentials(auth) if baseURL == "" { err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"} return @@ -116,7 +132,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } originalPayload := originalPayloadSource isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, translated, updatesChanged := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, opts.Stream, isCompat) + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, targetModel, originalPayload, req.Payload, opts.Stream, isCompat) + if err != nil { + return resp, err + } + originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel) + translated = helps.SetStringIfDifferent(translated, "model", baseModel) translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { @@ -125,14 +147,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) - if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) { + if helps.ShouldNormalizeOpenAIToolResultsForModel(compat, baseModel, requestedModel) { translated = helps.NormalizeOpenAIToolResultsTextOnly(translated) } if opts.Alt != "responses/compact" { - useMCT := helps.ShouldUseMaxCompletionTokensForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) + useMCT := helps.ShouldUseMaxCompletionTokensForModel(compat, baseModel, requestedModel) translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT) - translated, err = e.applyPromptCacheKey(ctx, auth, from, baseModel, req, opts, translated) + translated, err = e.applyPromptCacheKeyWithCompat(ctx, auth, compat, from, baseModel, req, opts, translated) if err != nil { return resp, err } @@ -141,11 +162,11 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil { translated = updated } - translated = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "openai compat executor", translated) + translated = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "openai compat executor", translated, true, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL) } - reporter.SetTranslatedReasoningEffort(translated, to.String()) - url := strings.TrimSuffix(baseURL, "/") + endpoint + translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedReasoningEffort(translated, to.String()) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) if err != nil { return resp, err @@ -154,7 +175,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A if apiKey != "" { httpReq.Header.Set("Authorization", "Bearer "+apiKey) } - httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") + ensureOpenAICompatUserAgent(httpReq, opts.Headers) var attrs map[string]string if auth != nil { attrs = auth.Attributes @@ -240,6 +261,10 @@ func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxy if contentType == "" { contentType = "application/json" } + payload, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), baseModel, "openai", payload, contentType, req, opts) + if errPrepare != nil { + return resp, errPrepare + } reporter.SetTranslatedReasoningEffort(payload, "openai") url := strings.TrimSuffix(baseURL, "/") + endpointPath @@ -251,7 +276,7 @@ func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxy if apiKey != "" { httpReq.Header.Set("Authorization", "Bearer "+apiKey) } - httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") + ensureOpenAICompatUserAgent(httpReq, opts.Headers) var attrs map[string]string if auth != nil { attrs = auth.Attributes @@ -315,13 +340,19 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if endpointPath := openAICompatImageEndpointPath(opts); endpointPath != "" { return e.executeImagesStream(ctx, auth, req, opts, endpointPath) } + baseURL, apiKey := e.resolveCredentials(auth) + compat := e.resolveCompatConfig(auth, req) + return e.executeStreamWithCredentials(ctx, auth, req, opts, baseURL, apiKey, compat) +} + +func (e *OpenAICompatExecutor) executeStreamWithCredentials(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (_ *cliproxyexecutor.StreamResult, err error) { + ctx = helps.EnsureSessionContext(ctx, opts, req.Payload) baseModel := thinking.ParseSuffix(req.Model).ModelName reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err) - baseURL, apiKey := e.resolveCredentials(auth) if baseURL == "" { err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"} return nil, err @@ -336,7 +367,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } originalPayload := originalPayloadSource isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, translated, updatesChanged := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat) + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, targetModel, originalPayload, req.Payload, true, isCompat) + if err != nil { + return nil, err + } + originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel) + translated = helps.SetStringIfDifferent(translated, "model", baseModel) translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { @@ -345,14 +382,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) - if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) { + if helps.ShouldNormalizeOpenAIToolResultsForModel(compat, baseModel, requestedModel) { translated = helps.NormalizeOpenAIToolResultsTextOnly(translated) } if opts.Alt != "responses/compact" { - useMCT := helps.ShouldUseMaxCompletionTokensForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) + useMCT := helps.ShouldUseMaxCompletionTokensForModel(compat, baseModel, requestedModel) translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT) - translated, err = e.applyPromptCacheKey(ctx, auth, from, baseModel, req, opts, translated) + translated, err = e.applyPromptCacheKeyWithCompat(ctx, auth, compat, from, baseModel, req, opts, translated) if err != nil { return nil, err } @@ -361,9 +397,10 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy // Request usage data in the final streaming chunk so that token statistics // are captured even when the upstream is an OpenAI-compatible provider. translated = helps.SetBoolIfDifferent(translated, "stream_options.include_usage", true) - reporter.SetTranslatedReasoningEffort(translated, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" + translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + reporter.SetTranslatedReasoningEffort(translated, to.String()) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) if err != nil { return nil, err @@ -372,7 +409,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if apiKey != "" { httpReq.Header.Set("Authorization", "Bearer "+apiKey) } - httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") + ensureOpenAICompatUserAgent(httpReq, opts.Headers) var attrs map[string]string if auth != nil { attrs = auth.Attributes @@ -399,7 +436,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy }) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) - httpClient = reporter.TrackHTTPClient(httpClient) + // Early role/empty frames are only first-packet fallbacks, not effective tokens. + httpClient = reporter.TrackHTTPClientRoundTripOnly(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) @@ -431,6 +469,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, ¶m) var streamUsage helps.StreamUsageBuffer var seenDone bool + var sawFinishReason bool var streamFailed bool var streamAborted bool var upstreamEvent string @@ -487,8 +526,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy publishStreamError(streamErr, true) return true } + if !sawFinishReason && openAICompatChunkHasFinishReason(dataPayload) { + sawFinishReason = true + } } + // Observe complete upstream frames before translation or downstream backpressure. + helps.ObserveChatTokenEvent(reporter, dataPayload) streamLine := append([]byte("data: "), dataPayload...) chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, streamLine, ¶m, claudeInputTokens) helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) @@ -515,7 +559,6 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) - reporter.ObserveResponseModel(line) streamUsage.ObserveOpenAIStream(line) trimmedLine := bytes.TrimSpace(line) if len(trimmedLine) == 0 { @@ -547,10 +590,26 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if !streamFailed && !streamAborted && helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } + if !streamFailed && !streamAborted && !seenDone && errScan == nil && responseFormat == sdktranslator.FormatOpenAIResponse && ctx.Err() == nil && helps.CanFinalizeResponseStream(param) { + finalChunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, []byte("data: [DONE]"), ¶m, claudeInputTokens) + for _, finalChunk := range finalChunks { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: finalChunk}: + case <-ctx.Done(): + streamAborted = true + } + if streamAborted { + break + } + } + if len(finalChunks) > 0 && !streamAborted { + seenDone = true + } + } if streamFailed || streamAborted { return } - if errScan != nil { + if errScan != nil && !seenDone { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) select { @@ -558,12 +617,16 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy case <-ctx.Done(): } } else if !seenDone { - // Responses clients require an explicit terminal event. Treat a clean - // upstream EOF without [DONE] as a failed stream instead of completing it. - if responseFormat == sdktranslator.FormatOpenAIResponse { + // Without a translator-confirmed terminal state, a clean Responses or Claude EOF + // without [DONE] remains a failed stream instead of completing it. + if responseFormat == sdktranslator.FormatOpenAIResponse || (responseFormat == sdktranslator.FormatClaude && !sawFinishReason) { streamErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before [DONE]"} helps.RecordAPIResponseError(ctx, e.cfg, streamErr) - reporter.PublishFailure(ctx, streamErr) + if !helps.RecordApplyPatchStreamFailureWithUsage(ctx, param, reporter, &streamUsage, streamErr) { + if !streamUsage.PublishFailure(ctx, reporter, streamErr) { + reporter.PublishFailure(ctx, streamErr) + } + } select { case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: case <-ctx.Done(): @@ -609,6 +672,10 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl if contentType == "" { contentType = "application/json" } + payload, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), baseModel, "openai", payload, contentType, req, opts) + if errPrepare != nil { + return nil, errPrepare + } reporter.SetTranslatedReasoningEffort(payload, "openai") url := strings.TrimSuffix(baseURL, "/") + endpointPath @@ -622,7 +689,7 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl if apiKey != "" { httpReq.Header.Set("Authorization", "Bearer "+apiKey) } - httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat") + ensureOpenAICompatUserAgent(httpReq, opts.Headers) var attrs map[string]string if auth != nil { attrs = auth.Attributes @@ -716,15 +783,23 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("openai") isCompat := helps.APIKeyModelIsCompat(req) - translated, updatesChanged := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, isCompat) + translated, updatesChanged, err := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, isCompat) + if err != nil { + return cliproxyexecutor.Response{}, err + } + originalTranslatedForPayload := append([]byte(nil), translated...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload, _, _ = helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false, isCompat) + } modelForCounting := baseModel - translated, err := helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) + translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { return cliproxyexecutor.Response{}, err } + translated = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translated) enc, err := helps.TokenizerForModel(modelForCounting) if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("openai compat executor: tokenizer init failed: %w", err) @@ -893,7 +968,10 @@ func rewriteOpenAICompatImagesMultipartPayload(payload []byte, model string, bou } func (e *OpenAICompatExecutor) applyPromptCacheKey(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, translated []byte) ([]byte, error) { - compat := e.resolveCompatConfig(auth, req) + return e.applyPromptCacheKeyWithCompat(ctx, auth, e.resolveCompatConfig(auth, req), from, baseModel, req, opts, translated) +} + +func (e *OpenAICompatExecutor) applyPromptCacheKeyWithCompat(ctx context.Context, auth *cliproxyauth.Auth, compat *config.OpenAICompatibility, from sdktranslator.Format, baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, translated []byte) ([]byte, error) { if compat == nil || !compat.SupportPromptCacheKey { return translated, nil } @@ -1031,6 +1109,15 @@ func openAICompatErrorEvent(eventName string) bool { return strings.EqualFold(eventName, "error") || strings.EqualFold(eventName, "response.error") || strings.EqualFold(eventName, "response.failed") } +func openAICompatChunkHasFinishReason(payload []byte) bool { + for _, choice := range gjson.GetBytes(payload, "choices").Array() { + if reason := choice.Get("finish_reason"); reason.Exists() && reason.Type != gjson.Null && strings.TrimSpace(reason.String()) != "" { + return true + } + } + return false +} + func openAICompatStreamDataError(payload []byte, eventName string) (statusErr, bool) { if len(payload) == 0 || !json.Valid(payload) { return statusErr{}, false @@ -1068,6 +1155,7 @@ type statusErr struct { msg string retryAfter *time.Duration credentialScoped bool + streamTruncated bool } func (e statusErr) Error() string { @@ -1080,6 +1168,10 @@ func (e statusErr) StatusCode() int { return e.code } func (e statusErr) RetryAfter() *time.Duration { return e.retryAfter } func (e statusErr) IsCredentialScoped() bool { return e.credentialScoped } +// IsStreamTruncated marks a locally synthesized failure for an accepted +// upstream stream that ended without its terminal frame. +func (e statusErr) IsStreamTruncated() bool { return e.streamTruncated } + const openAICompatTPMFallbackRetryAfter = time.Minute func newOpenAICompatStatusError(status int, headers http.Header, body []byte) statusErr { diff --git a/internal/runtime/executor/openai_responses_signature.go b/internal/runtime/executor/openai_responses_signature.go index fa7d6a24..59469dd3 100644 --- a/internal/runtime/executor/openai_responses_signature.go +++ b/internal/runtime/executor/openai_responses_signature.go @@ -3,11 +3,14 @@ package executor import ( "context" "fmt" + "net/url" "strings" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -48,11 +51,138 @@ func promoteOpenAIResponsesReasoningTextToSummary(itemRaw string, content gjson. return sjson.SetRaw(itemRaw, "summary", b.String()) } +type metaSignaturePolicy int + +const ( + // metaSignatureForeign: target is not Meta; a recognized Meta envelope is foreign. + metaSignatureForeign metaSignaturePolicy = iota + // metaSignatureNativeOnly: native Meta upstream (api.meta.ai); only unsuffixed envelopes replay. + metaSignatureNativeOnly + // metaSignatureAllowRouted: Meta-family model served by a router (e.g. OpenRouter meta/muse-*); native or router-suffixed envelopes replay verbatim. + metaSignatureAllowRouted +) + func sanitizeOpenAIResponsesReasoningEncryptedContent(ctx context.Context, provider string, body []byte) []byte { - return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, provider, body, false) + return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, false, metaSignatureForeign, signature.SignatureProviderUnknown, false) } func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx context.Context, provider string, body []byte, isCompat bool) []byte { + return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(ctx, provider, body, isCompat, "") +} + +// sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel is the +// compat variant that knows its target model: a positively recognized Meta +// Muse envelope is preserved whenever the upstream actually serves a +// Meta-family model, whether or not the route is flagged compat (native +// OpenAI never serves muse-* models, but a router key such as an OpenRouter +// codex endpoint can serve meta/muse-* through the Codex executor without +// isCompat). Router-suffixed envelopes replay verbatim there; the native +// Meta executor keeps its own stricter native-only gate. For any other +// target the recognized Meta envelope is known-foreign and stripped like +// other providers'. +// resolveResponsesSignatureTargetModel returns the canonical model name to use +// for reasoning signature target checks, falling back to the client-requested +// model when an upstream alias mapping hides the Meta Muse family name. +func resolveResponsesSignatureTargetModel(baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string { + if signature.SignatureProviderFromModelName(baseModel) != signature.SignatureProviderUnknown { + return baseModel + } + requested := thinking.ParseSuffix(helps.PayloadRequestedModel(opts, req.Model)).ModelName + switch signature.SignatureProviderFromModelName(requested) { + case signature.SignatureProviderMeta, signature.SignatureProviderGrok, signature.SignatureProviderGPT: + return requested + } + return baseModel +} + +func resolveMetaSignatureTargetModel(baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string { + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + if signature.SignatureProviderFromModelName(targetModel) == signature.SignatureProviderMeta { + return targetModel + } + return "muse-" + baseModel +} + +func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(ctx context.Context, provider string, body []byte, isCompat bool, targetModel string) []byte { + return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, provider, body, isCompat, targetModel, "") +} + +func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx context.Context, provider string, body []byte, isCompat bool, targetModel string, baseURL string) []byte { + targetProvider := signature.SignatureProviderFromModelName(targetModel) + metaPolicy := metaSignatureForeign + if targetProvider == signature.SignatureProviderMeta { + if strings.TrimSpace(baseURL) != "" && isNativeMetaUpstreamURL(baseURL) { + metaPolicy = metaSignatureNativeOnly + } else { + metaPolicy = metaSignatureAllowRouted + } + } + return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, isCompat, isCompat, metaPolicy, targetProvider, isOpenRouterUpstreamURL(baseURL)) +} + +func sanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign(ctx context.Context, provider string, body []byte) []byte { + return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureForeign, signature.SignatureProviderUnknown, false) +} + +// sanitizeOpenAIResponsesReasoningEncryptedContentForMeta is the Meta-target +// variant and is deliberately native-only: only positively recognized Meta +// Muse envelopes without a router suffix may replay to api.meta.ai, whose +// suffix acceptance is unverified. GPT, other known-provider, +// unknown-format, and router-suffixed values are all stripped, so a wrong +// signature can never reach the Meta upstream through this path. A future +// Meta envelope revision will be stripped until the inspector learns it +// (fail-closed). +func sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(ctx context.Context, provider string, body []byte) []byte { + return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureNativeOnly, signature.SignatureProviderMeta, false) +} + +// sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL gates Meta +// reasoning signatures by whether baseURL is the native Meta upstream +// (api.meta.ai, which accepts only unsuffixed envelopes) or a third-party +// Responses provider configured under meta-api-key (which may emit and consume +// router-suffixed Meta Muse envelopes verbatim when targeting OpenRouter, or +// strip the OpenRouter suffix for other providers). +func sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(ctx context.Context, provider string, body []byte, baseURL string) []byte { + if isNativeMetaUpstreamURL(baseURL) { + return sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(ctx, provider, body) + } + return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureAllowRouted, signature.SignatureProviderMeta, isOpenRouterUpstreamURL(baseURL)) +} + +func isNativeMetaUpstreamURL(rawURL string) bool { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return true + } + parsed, err := url.Parse(rawURL) + if err != nil { + return true + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + return host == "" || host == "api.meta.ai" || strings.HasSuffix(host, ".meta.ai") +} + +func isOpenRouterUpstreamURL(rawURL string) bool { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return false + } + parsed, err := url.Parse(rawURL) + if err != nil { + return false + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + return host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai") +} + +func stripRouterSuffix(rawSignature, suffix string) string { + if suffix == "" || len(rawSignature) <= len(suffix)+1 { + return rawSignature + } + return rawSignature[:len(rawSignature)-len(suffix)-1] +} + +func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Context, provider string, body []byte, isCompat bool, keepForeign bool, metaPolicy metaSignaturePolicy, targetProvider signature.SignatureProvider, allowRouterSuffix bool) []byte { inputResult := util.GetGJSONBytesNoCopy(body, "input") if !inputResult.Exists() || !inputResult.IsArray() { return body @@ -162,13 +292,73 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx context.Cont } reason := "" + rewrittenSignature := "" switch encryptedContent.Type { case gjson.String: rawSignature := encryptedContent.String() if rawSignature != strings.TrimSpace(rawSignature) { reason = "encrypted_content has leading or trailing whitespace" - } else if _, err := signature.InspectGPTReasoningSignature(rawSignature); err != nil { - reason = err.Error() + } else if metaPolicy != metaSignatureForeign { + // Meta targets never reach the GPT-first gate: a GPT, + // foreign-provider, or unknown-format blob must not replay to + // Meta. Only a positively recognized Meta envelope survives, + // and the native Meta upstream additionally refuses the + // router suffix a router such as OpenRouter appends. + if info, err := signature.InspectMetaReasoningSignature(rawSignature); err != nil { + reason = err.Error() + } else if info.RouterSuffix != "" { + if metaPolicy == metaSignatureNativeOnly { + reason = "router-suffixed Meta reasoning signature is not accepted by the native Meta upstream" + } else if !allowRouterSuffix { + rewrittenSignature = stripRouterSuffix(rawSignature, info.RouterSuffix) + } + } + } else if targetProvider == signature.SignatureProviderUnknown || targetProvider == signature.SignatureProviderGPT { + candidate := rawSignature + hasGPTPrefix := false + if prefixedProvider, unprefixed, ok := signature.SplitSignatureProviderPrefix(rawSignature); ok && prefixedProvider == signature.SignatureProviderGPT { + candidate = unprefixed + hasGPTPrefix = true + } + if gptInfo, err := signature.InspectGPTReasoningSignature(candidate); err == nil { + if gptInfo.RouterSuffix != "" && !allowRouterSuffix { + rewrittenSignature = stripRouterSuffix(candidate, gptInfo.RouterSuffix) + } else if hasGPTPrefix { + rewrittenSignature = candidate + } + } else if !(keepForeign && rawSignature != "" && signature.DetectSignatureProvider(rawSignature) == signature.SignatureProviderUnknown) { + reason = err.Error() + } + } else { + // Target model belongs to a known non-GPT, non-Meta family (such as + // Grok or Gemini served through a Responses router like OpenRouter). + // Only preserve signatures that match that target family (or + // unknown-format blobs when keepForeign is enabled); foreign GPT or + // other known-provider envelopes are stripped. + keep := false + if rawSignature != "" { + switch targetProvider { + case signature.SignatureProviderGrok: + if grokInfo, errGrok := signature.InspectGrokEncryptedContent(rawSignature); errGrok == nil { + keep = true + if grokInfo.RouterSuffix != "" && !allowRouterSuffix { + rewrittenSignature = stripRouterSuffix(rawSignature, grokInfo.RouterSuffix) + } + } + case signature.SignatureProviderGemini, signature.SignatureProviderClaude, signature.SignatureProviderKimi, signature.SignatureProviderSWE: + var normalized string + normalized, keep = signature.CompatibleSignatureForProvider(targetProvider, rawSignature) + if keep && normalized != "" && normalized != rawSignature { + rewrittenSignature = normalized + } + } + if !keep && keepForeign { + keep = signature.DetectSignatureProvider(rawSignature) == signature.SignatureProviderUnknown + } + } + if !keep { + reason = fmt.Sprintf("reasoning encrypted_content is not compatible with target provider %s", targetProvider) + } } case gjson.Null: reason = "encrypted_content is null" @@ -176,6 +366,13 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx context.Cont reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String()) } if reason == "" { + if rewrittenSignature != "" { + if updatedItem, errSet := sjson.Set(nextItem, "encrypted_content", rewrittenSignature); errSet == nil { + nextItem = updatedItem + changed = true + helps.LogWithRequestID(ctx).Debugf("%s: stripped router suffix from reasoning encrypted_content at input[%d] item_id=%q", provider, index, itemID) + } + } if !changed { keep(item.Raw) continue