mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-29 16:54:41 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54d4fd7f84 | ||
|
|
8dc690a638 | ||
|
|
fdeb84db2b | ||
|
|
84920cb670 | ||
|
|
204bba9dea | ||
|
|
35fdd7bc05 | ||
|
|
fc054db51a | ||
|
|
6e2306a5f2 | ||
|
|
b09e2115d1 | ||
|
|
6a94afab6c | ||
|
|
a68c97a40f | ||
|
|
40e7f066e4 |
@@ -3,6 +3,8 @@ package amp
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -148,7 +150,13 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
|
|||||||
|
|
||||||
// Error handler for proxy failures
|
// Error handler for proxy failures
|
||||||
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
|
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
|
||||||
log.Errorf("amp upstream proxy error for %s %s: %v", req.Method, req.URL.Path, err)
|
// Check if this is a client-side cancellation (normal behavior)
|
||||||
|
// Don't log as error for context canceled - it's usually client closing connection
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
log.Debugf("amp upstream proxy: client canceled request for %s %s", req.Method, req.URL.Path)
|
||||||
|
} else {
|
||||||
|
log.Errorf("amp upstream proxy error for %s %s: %v", req.Method, req.URL.Path, err)
|
||||||
|
}
|
||||||
rw.Header().Set("Content-Type", "application/json")
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusBadGateway)
|
rw.WriteHeader(http.StatusBadGateway)
|
||||||
_, _ = rw.Write([]byte(`{"error":"amp_upstream_proxy_error","message":"Failed to reach Amp upstream"}`))
|
_, _ = rw.Write([]byte(`{"error":"amp_upstream_proxy_error","message":"Failed to reach Amp upstream"}`))
|
||||||
|
|||||||
@@ -349,6 +349,12 @@ func (s *Server) setupRoutes() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Event logging endpoint - handles Claude Code telemetry requests
|
||||||
|
// Returns 200 OK to prevent 404 errors in logs
|
||||||
|
s.engine.POST("/api/event_logging/batch", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
|
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
|
||||||
|
|
||||||
// OAuth callback endpoints (reuse main server port)
|
// OAuth callback endpoints (reuse main server port)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ type Config struct {
|
|||||||
// KiroKey defines a list of Kiro (AWS CodeWhisperer) configurations.
|
// KiroKey defines a list of Kiro (AWS CodeWhisperer) configurations.
|
||||||
KiroKey []KiroKey `yaml:"kiro" json:"kiro"`
|
KiroKey []KiroKey `yaml:"kiro" json:"kiro"`
|
||||||
|
|
||||||
|
// KiroPreferredEndpoint sets the global default preferred endpoint for all Kiro providers.
|
||||||
|
// Values: "ide" (default, CodeWhisperer) or "cli" (Amazon Q).
|
||||||
|
KiroPreferredEndpoint string `yaml:"kiro-preferred-endpoint" json:"kiro-preferred-endpoint"`
|
||||||
|
|
||||||
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
||||||
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
||||||
|
|
||||||
@@ -278,6 +282,10 @@ type KiroKey struct {
|
|||||||
// AgentTaskType sets the Kiro API task type. Known values: "vibe", "dev", "chat".
|
// AgentTaskType sets the Kiro API task type. Known values: "vibe", "dev", "chat".
|
||||||
// Leave empty to let API use defaults. Different values may inject different system prompts.
|
// Leave empty to let API use defaults. Different values may inject different system prompts.
|
||||||
AgentTaskType string `yaml:"agent-task-type,omitempty" json:"agent-task-type,omitempty"`
|
AgentTaskType string `yaml:"agent-task-type,omitempty" json:"agent-task-type,omitempty"`
|
||||||
|
|
||||||
|
// PreferredEndpoint sets the preferred Kiro API endpoint/quota.
|
||||||
|
// Values: "codewhisperer" (default, IDE quota) or "amazonq" (CLI quota).
|
||||||
|
PreferredEndpoint string `yaml:"preferred-endpoint,omitempty" json:"preferred-endpoint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
||||||
@@ -504,6 +512,7 @@ func (cfg *Config) SanitizeKiroKeys() {
|
|||||||
entry.ProfileArn = strings.TrimSpace(entry.ProfileArn)
|
entry.ProfileArn = strings.TrimSpace(entry.ProfileArn)
|
||||||
entry.Region = strings.TrimSpace(entry.Region)
|
entry.Region = strings.TrimSpace(entry.Region)
|
||||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||||
|
entry.PreferredEndpoint = strings.TrimSpace(entry.PreferredEndpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -580,7 +580,7 @@ func GetOpenAIModels() []*ModelInfo {
|
|||||||
ContextLength: 400000,
|
ContextLength: 400000,
|
||||||
MaxCompletionTokens: 128000,
|
MaxCompletionTokens: 128000,
|
||||||
SupportedParameters: []string{"tools"},
|
SupportedParameters: []string{"tools"},
|
||||||
Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high", "xhigh"}},
|
Thinking: &ThinkingSupport{Levels: []string{"none", "low", "medium", "high", "xhigh"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -884,8 +884,9 @@ func GetGitHubCopilotModels() []*ModelInfo {
|
|||||||
// GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions
|
// GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions
|
||||||
func GetKiroModels() []*ModelInfo {
|
func GetKiroModels() []*ModelInfo {
|
||||||
return []*ModelInfo{
|
return []*ModelInfo{
|
||||||
|
// --- Base Models ---
|
||||||
{
|
{
|
||||||
ID: "kiro-claude-opus-4.5",
|
ID: "kiro-claude-opus-4-5",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: 1732752000,
|
Created: 1732752000,
|
||||||
OwnedBy: "aws",
|
OwnedBy: "aws",
|
||||||
@@ -896,7 +897,7 @@ func GetKiroModels() []*ModelInfo {
|
|||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "kiro-claude-sonnet-4.5",
|
ID: "kiro-claude-sonnet-4-5",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: 1732752000,
|
Created: 1732752000,
|
||||||
OwnedBy: "aws",
|
OwnedBy: "aws",
|
||||||
@@ -918,7 +919,7 @@ func GetKiroModels() []*ModelInfo {
|
|||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "kiro-claude-haiku-4.5",
|
ID: "kiro-claude-haiku-4-5",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: 1732752000,
|
Created: 1732752000,
|
||||||
OwnedBy: "aws",
|
OwnedBy: "aws",
|
||||||
@@ -928,21 +929,9 @@ func GetKiroModels() []*ModelInfo {
|
|||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
},
|
},
|
||||||
// --- Chat Variant (No tool calling, for pure conversation) ---
|
|
||||||
{
|
|
||||||
ID: "kiro-claude-opus-4.5-chat",
|
|
||||||
Object: "model",
|
|
||||||
Created: 1732752000,
|
|
||||||
OwnedBy: "aws",
|
|
||||||
Type: "kiro",
|
|
||||||
DisplayName: "Kiro Claude Opus 4.5 (Chat)",
|
|
||||||
Description: "Claude Opus 4.5 for chat only (no tool calling)",
|
|
||||||
ContextLength: 200000,
|
|
||||||
MaxCompletionTokens: 64000,
|
|
||||||
},
|
|
||||||
// --- Agentic Variants (Optimized for coding agents with chunked writes) ---
|
// --- Agentic Variants (Optimized for coding agents with chunked writes) ---
|
||||||
{
|
{
|
||||||
ID: "kiro-claude-opus-4.5-agentic",
|
ID: "kiro-claude-opus-4-5-agentic",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: 1732752000,
|
Created: 1732752000,
|
||||||
OwnedBy: "aws",
|
OwnedBy: "aws",
|
||||||
@@ -953,7 +942,7 @@ func GetKiroModels() []*ModelInfo {
|
|||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "kiro-claude-sonnet-4.5-agentic",
|
ID: "kiro-claude-sonnet-4-5-agentic",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: 1732752000,
|
Created: 1732752000,
|
||||||
OwnedBy: "aws",
|
OwnedBy: "aws",
|
||||||
@@ -963,6 +952,28 @@ func GetKiroModels() []*ModelInfo {
|
|||||||
ContextLength: 200000,
|
ContextLength: 200000,
|
||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "kiro-claude-sonnet-4-agentic",
|
||||||
|
Object: "model",
|
||||||
|
Created: 1732752000,
|
||||||
|
OwnedBy: "aws",
|
||||||
|
Type: "kiro",
|
||||||
|
DisplayName: "Kiro Claude Sonnet 4 (Agentic)",
|
||||||
|
Description: "Claude Sonnet 4 optimized for coding agents (chunked writes)",
|
||||||
|
ContextLength: 200000,
|
||||||
|
MaxCompletionTokens: 64000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kiro-claude-haiku-4-5-agentic",
|
||||||
|
Object: "model",
|
||||||
|
Created: 1732752000,
|
||||||
|
OwnedBy: "aws",
|
||||||
|
Type: "kiro",
|
||||||
|
DisplayName: "Kiro Claude Haiku 4.5 (Agentic)",
|
||||||
|
Description: "Claude Haiku 4.5 optimized for coding agents (chunked writes)",
|
||||||
|
ContextLength: 200000,
|
||||||
|
MaxCompletionTokens: 64000,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,18 @@ import (
|
|||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// reasoningEffortToBudget maps OpenAI reasoning_effort values to Claude thinking budget_tokens.
|
||||||
|
// OpenAI uses "low", "medium", "high" while Claude uses numeric budget_tokens.
|
||||||
|
var reasoningEffortToBudget = map[string]int{
|
||||||
|
"low": 4000,
|
||||||
|
"medium": 16000,
|
||||||
|
"high": 32000,
|
||||||
|
}
|
||||||
|
|
||||||
// ConvertOpenAIRequestToKiro transforms an OpenAI Chat Completions API request into Kiro (Claude) format.
|
// ConvertOpenAIRequestToKiro transforms an OpenAI Chat Completions API request into Kiro (Claude) format.
|
||||||
// Kiro uses Claude-compatible format internally, so we primarily pass through to Claude format.
|
// Kiro uses Claude-compatible format internally, so we primarily pass through to Claude format.
|
||||||
// Supports tool calling: OpenAI tools -> Claude tools, tool_calls -> tool_use, tool messages -> tool_result.
|
// Supports tool calling: OpenAI tools -> Claude tools, tool_calls -> tool_use, tool messages -> tool_result.
|
||||||
|
// Supports reasoning/thinking: OpenAI reasoning_effort -> Claude thinking parameter.
|
||||||
func ConvertOpenAIRequestToKiro(modelName string, inputRawJSON []byte, stream bool) []byte {
|
func ConvertOpenAIRequestToKiro(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||||
rawJSON := bytes.Clone(inputRawJSON)
|
rawJSON := bytes.Clone(inputRawJSON)
|
||||||
root := gjson.ParseBytes(rawJSON)
|
root := gjson.ParseBytes(rawJSON)
|
||||||
@@ -38,6 +47,26 @@ func ConvertOpenAIRequestToKiro(modelName string, inputRawJSON []byte, stream bo
|
|||||||
out, _ = sjson.Set(out, "top_p", v.Float())
|
out, _ = sjson.Set(out, "top_p", v.Float())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle OpenAI reasoning_effort parameter -> Claude thinking parameter
|
||||||
|
// OpenAI format: {"reasoning_effort": "low"|"medium"|"high"}
|
||||||
|
// Claude format: {"thinking": {"type": "enabled", "budget_tokens": N}}
|
||||||
|
if v := root.Get("reasoning_effort"); v.Exists() {
|
||||||
|
effort := v.String()
|
||||||
|
if budget, ok := reasoningEffortToBudget[effort]; ok {
|
||||||
|
thinking := map[string]interface{}{
|
||||||
|
"type": "enabled",
|
||||||
|
"budget_tokens": budget,
|
||||||
|
}
|
||||||
|
out, _ = sjson.Set(out, "thinking", thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also support direct thinking parameter passthrough (for Claude API compatibility)
|
||||||
|
// Claude format: {"thinking": {"type": "enabled", "budget_tokens": N}}
|
||||||
|
if v := root.Get("thinking"); v.Exists() && v.IsObject() {
|
||||||
|
out, _ = sjson.Set(out, "thinking", v.Value())
|
||||||
|
}
|
||||||
|
|
||||||
// Convert OpenAI tools to Claude tools format
|
// Convert OpenAI tools to Claude tools format
|
||||||
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
|
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
|
||||||
claudeTools := make([]interface{}, 0)
|
claudeTools := make([]interface{}, 0)
|
||||||
|
|||||||
@@ -134,6 +134,28 @@ func convertClaudeEventToOpenAI(jsonStr string, model string) []string {
|
|||||||
result, _ := json.Marshal(response)
|
result, _ := json.Marshal(response)
|
||||||
results = append(results, string(result))
|
results = append(results, string(result))
|
||||||
}
|
}
|
||||||
|
} else if deltaType == "thinking_delta" {
|
||||||
|
// Thinking/reasoning content delta - convert to OpenAI reasoning_content format
|
||||||
|
thinkingDelta := root.Get("delta.thinking").String()
|
||||||
|
if thinkingDelta != "" {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"id": "chatcmpl-" + uuid.New().String()[:24],
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": time.Now().Unix(),
|
||||||
|
"model": model,
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": map[string]interface{}{
|
||||||
|
"reasoning_content": thinkingDelta,
|
||||||
|
},
|
||||||
|
"finish_reason": nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, _ := json.Marshal(response)
|
||||||
|
results = append(results, string(result))
|
||||||
|
}
|
||||||
} else if deltaType == "input_json_delta" {
|
} else if deltaType == "input_json_delta" {
|
||||||
// Tool input delta (streaming arguments)
|
// Tool input delta (streaming arguments)
|
||||||
partialJSON := root.Get("delta.partial_json").String()
|
partialJSON := root.Get("delta.partial_json").String()
|
||||||
@@ -298,6 +320,7 @@ func ConvertKiroResponseToOpenAINonStream(ctx context.Context, model string, ori
|
|||||||
root := gjson.ParseBytes(rawResponse)
|
root := gjson.ParseBytes(rawResponse)
|
||||||
|
|
||||||
var content string
|
var content string
|
||||||
|
var reasoningContent string
|
||||||
var toolCalls []map[string]interface{}
|
var toolCalls []map[string]interface{}
|
||||||
|
|
||||||
contentArray := root.Get("content")
|
contentArray := root.Get("content")
|
||||||
@@ -306,6 +329,9 @@ func ConvertKiroResponseToOpenAINonStream(ctx context.Context, model string, ori
|
|||||||
itemType := item.Get("type").String()
|
itemType := item.Get("type").String()
|
||||||
if itemType == "text" {
|
if itemType == "text" {
|
||||||
content += item.Get("text").String()
|
content += item.Get("text").String()
|
||||||
|
} else if itemType == "thinking" {
|
||||||
|
// Extract thinking/reasoning content
|
||||||
|
reasoningContent += item.Get("thinking").String()
|
||||||
} else if itemType == "tool_use" {
|
} else if itemType == "tool_use" {
|
||||||
// Convert Claude tool_use to OpenAI tool_calls format
|
// Convert Claude tool_use to OpenAI tool_calls format
|
||||||
inputJSON := item.Get("input").String()
|
inputJSON := item.Get("input").String()
|
||||||
@@ -339,6 +365,11 @@ func ConvertKiroResponseToOpenAINonStream(ctx context.Context, model string, ori
|
|||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add reasoning_content if present (OpenAI reasoning format)
|
||||||
|
if reasoningContent != "" {
|
||||||
|
message["reasoning_content"] = reasoningContent
|
||||||
|
}
|
||||||
|
|
||||||
// Add tool_calls if present
|
// Add tool_calls if present
|
||||||
if len(toolCalls) > 0 {
|
if len(toolCalls) > 0 {
|
||||||
message["tool_calls"] = toolCalls
|
message["tool_calls"] = toolCalls
|
||||||
|
|||||||
@@ -1317,6 +1317,12 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
if kk.AgentTaskType != "" {
|
if kk.AgentTaskType != "" {
|
||||||
attrs["agent_task_type"] = kk.AgentTaskType
|
attrs["agent_task_type"] = kk.AgentTaskType
|
||||||
}
|
}
|
||||||
|
if kk.PreferredEndpoint != "" {
|
||||||
|
attrs["preferred_endpoint"] = kk.PreferredEndpoint
|
||||||
|
} else if cfg.KiroPreferredEndpoint != "" {
|
||||||
|
// Apply global default if not overridden by specific key
|
||||||
|
attrs["preferred_endpoint"] = cfg.KiroPreferredEndpoint
|
||||||
|
}
|
||||||
if refreshToken != "" {
|
if refreshToken != "" {
|
||||||
attrs["refresh_token"] = refreshToken
|
attrs["refresh_token"] = refreshToken
|
||||||
}
|
}
|
||||||
@@ -1532,6 +1538,17 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
a.NextRefreshAfter = expiresAt.Add(-30 * time.Minute)
|
a.NextRefreshAfter = expiresAt.Add(-30 * time.Minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply global preferred endpoint setting if not present in metadata
|
||||||
|
if cfg.KiroPreferredEndpoint != "" {
|
||||||
|
// Check if already set in metadata (which takes precedence in executor)
|
||||||
|
if _, hasMeta := metadata["preferred_endpoint"]; !hasMeta {
|
||||||
|
if a.Attributes == nil {
|
||||||
|
a.Attributes = make(map[string]string)
|
||||||
|
}
|
||||||
|
a.Attributes["preferred_endpoint"] = cfg.KiroPreferredEndpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyAuthExcludedModelsMeta(a, cfg, nil, "oauth")
|
applyAuthExcludedModelsMeta(a, cfg, nil, "oauth")
|
||||||
|
|||||||
@@ -136,19 +136,29 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
|
|||||||
newCtx = context.WithValue(newCtx, "gin", c)
|
newCtx = context.WithValue(newCtx, "gin", c)
|
||||||
newCtx = context.WithValue(newCtx, "handler", handler)
|
newCtx = context.WithValue(newCtx, "handler", handler)
|
||||||
return newCtx, func(params ...interface{}) {
|
return newCtx, func(params ...interface{}) {
|
||||||
if h.Cfg.RequestLog {
|
if h.Cfg.RequestLog && len(params) == 1 {
|
||||||
if len(params) == 1 {
|
var payload []byte
|
||||||
data := params[0]
|
switch data := params[0].(type) {
|
||||||
switch data.(type) {
|
case []byte:
|
||||||
case []byte:
|
payload = data
|
||||||
appendAPIResponse(c, data.([]byte))
|
case error:
|
||||||
case error:
|
if data != nil {
|
||||||
appendAPIResponse(c, []byte(data.(error).Error()))
|
payload = []byte(data.Error())
|
||||||
case string:
|
|
||||||
appendAPIResponse(c, []byte(data.(string)))
|
|
||||||
case bool:
|
|
||||||
case nil:
|
|
||||||
}
|
}
|
||||||
|
case string:
|
||||||
|
payload = []byte(data)
|
||||||
|
}
|
||||||
|
if len(payload) > 0 {
|
||||||
|
if existing, exists := c.Get("API_RESPONSE"); exists {
|
||||||
|
if existingBytes, ok := existing.([]byte); ok && len(existingBytes) > 0 {
|
||||||
|
trimmedPayload := bytes.TrimSpace(payload)
|
||||||
|
if len(trimmedPayload) > 0 && bytes.Contains(existingBytes, trimmedPayload) {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendAPIResponse(c, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user