diff --git a/internal/agent/component/model_driver_test.go b/internal/agent/component/model_driver_test.go
index 84fc1b288b..69503da5e8 100644
--- a/internal/agent/component/model_driver_test.go
+++ b/internal/agent/component/model_driver_test.go
@@ -12,6 +12,7 @@ import (
)
func TestNewChatModelDriverPreservesProviderChatSuffix(t *testing.T) {
+ setupAllowAnyHost(t, true)
if err := models.InitProviderManager("../../../conf/models"); err != nil {
t.Fatalf("InitProviderManager: %v", err)
}
diff --git a/internal/entity/models/302ai.go b/internal/entity/models/302ai.go
index fa2dfaa5e6..2f3b4e1769 100644
--- a/internal/entity/models/302ai.go
+++ b/internal/entity/models/302ai.go
@@ -42,7 +42,7 @@ func NewAI302Model(baseURL map[string]string, urlSuffix URLSuffix) *AI302Model {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/302ai_test.go b/internal/entity/models/302ai_test.go
index 1c7eace058..fbb8f52807 100644
--- a/internal/entity/models/302ai_test.go
+++ b/internal/entity/models/302ai_test.go
@@ -69,6 +69,7 @@ func newAI302ForTest(baseURL string) *AI302Model {
}
func TestAI302ChatForcesNonStreaming(t *testing.T) {
+ withSSRFBypass(t)
srv := newAI302Server(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodPost {
t.Errorf("method=%s, want POST", r.Method)
@@ -114,6 +115,7 @@ func TestAI302ChatForcesNonStreaming(t *testing.T) {
}
func TestAI302StreamForcesStreaming(t *testing.T) {
+ withSSRFBypass(t)
srv := newAI302Server(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path=%s, want /v1/chat/completions", r.URL.Path)
@@ -167,6 +169,7 @@ func TestAI302StreamForcesStreaming(t *testing.T) {
}
func TestAI302ListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newAI302Server(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
t.Errorf("method=%s, want GET", r.Method)
@@ -195,6 +198,7 @@ func TestAI302ListModelsHappyPath(t *testing.T) {
}
func TestAI302ListModelsRejectsMalformedResponse(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
for name, response := range map[string]interface{}{
"missing data": map[string]interface{}{"object": "list"},
@@ -215,6 +219,7 @@ func TestAI302ListModelsRejectsMalformedResponse(t *testing.T) {
}
func TestAI302ShowTaskEscapesTaskID(t *testing.T) {
+ withSSRFBypass(t)
srv := newAI302Server(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
t.Errorf("method=%s, want GET", r.Method)
@@ -245,6 +250,7 @@ func TestAI302ShowTaskEscapesTaskID(t *testing.T) {
}
func TestAI302ValidatesInputs(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
emptyKey := " "
model := "gpt-5"
diff --git a/internal/entity/models/aliyun.go b/internal/entity/models/aliyun.go
index fc2cdc208e..6dcad3dff5 100644
--- a/internal/entity/models/aliyun.go
+++ b/internal/entity/models/aliyun.go
@@ -39,7 +39,7 @@ func NewAliyunModel(baseURL map[string]string, urlSuffix URLSuffix) *AliyunModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/aliyun_test.go b/internal/entity/models/aliyun_test.go
index 4d5d86c569..e9ae59263e 100644
--- a/internal/entity/models/aliyun_test.go
+++ b/internal/entity/models/aliyun_test.go
@@ -25,6 +25,7 @@ import (
)
func TestAliyunChatWithMessagesSupportsToolCalls(t *testing.T) {
+ withSSRFBypass(t)
requestBody := make(chan map[string]interface{}, 1)
requestPath := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -87,6 +88,7 @@ func TestAliyunChatWithMessagesSupportsToolCalls(t *testing.T) {
}
func TestAliyunChatWithMessagesStopsQwenFlashAfterToolResult(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
requestBody := make(chan map[string]interface{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -188,6 +190,7 @@ func TestAliyunToolChoiceStopsOnlyQwenFlashAfterToolResult(t *testing.T) {
}
func TestAliyunChatStreamlyWithSenderSupportsToolCalls(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
requestBody := make(chan map[string]interface{}, 1)
requestPath := make(chan string, 1)
@@ -282,6 +285,7 @@ data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ra
}
func TestAliyunChatStreamlyWithSenderRejectsStreamFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := NewAliyunModel(
map[string]string{"default": "https://dashscope.example"},
diff --git a/internal/entity/models/anthropic.go b/internal/entity/models/anthropic.go
index cb3155a3ae..8513d319ae 100644
--- a/internal/entity/models/anthropic.go
+++ b/internal/entity/models/anthropic.go
@@ -41,7 +41,7 @@ func NewAnthropicModel(baseURL map[string]string, urlSuffix URLSuffix) *Anthropi
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/anthropic_test.go b/internal/entity/models/anthropic_test.go
index 2c8db61e64..8540f456af 100644
--- a/internal/entity/models/anthropic_test.go
+++ b/internal/entity/models/anthropic_test.go
@@ -61,6 +61,7 @@ func TestAnthropicName(t *testing.T) {
}
func TestAnthropicChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "claude-sonnet-4-5-20250929" {
t.Errorf("model=%v", body["model"])
@@ -109,6 +110,7 @@ func TestAnthropicChatHappyPath(t *testing.T) {
}
func TestAnthropicChatMapsSystemConfigAndImages(t *testing.T) {
+ withSSRFBypass(t)
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["system"] != "be concise" {
t.Errorf("system=%v, want be concise", body["system"])
@@ -186,6 +188,7 @@ func TestAnthropicChatMapsSystemConfigAndImages(t *testing.T) {
}
func TestAnthropicChatMapsDataImageURL(t *testing.T) {
+ withSSRFBypass(t)
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
msgs, ok := body["messages"].([]interface{})
if !ok || len(msgs) == 0 {
@@ -239,6 +242,7 @@ func TestAnthropicChatMapsDataImageURL(t *testing.T) {
}
func TestAnthropicChatValidationErrors(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newAnthropicForTest("http://unused")
apiKey := "test-key"
@@ -263,6 +267,7 @@ func TestAnthropicChatValidationErrors(t *testing.T) {
}
func TestAnthropicChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -278,6 +283,7 @@ func TestAnthropicChatRejectsHTTPError(t *testing.T) {
}
func TestAnthropicChatRejectsMalformedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"content": []map[string]interface{}{{"type": "tool_use"}}})
@@ -292,6 +298,7 @@ func TestAnthropicChatRejectsMalformedResponse(t *testing.T) {
}
func TestAnthropicChatStreamlyWithSenderHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["stream"] != true {
t.Errorf("stream=%v, want true", body["stream"])
@@ -364,6 +371,7 @@ func TestAnthropicChatStreamlyWithSenderHappyPath(t *testing.T) {
}
func TestAnthropicChatStreamlyWithSenderRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"bad key"}}`))
@@ -387,6 +395,7 @@ func TestAnthropicChatStreamlyWithSenderRejectsHTTPError(t *testing.T) {
}
func TestAnthropicChatStreamlyWithSenderRejectsStreamError(t *testing.T) {
+ withSSRFBypass(t)
srv := newAnthropicServer(t, "/v1/messages", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`data: {"type":"error","error":{"message":"overloaded"}}` + "\n\n"))
@@ -410,6 +419,7 @@ func TestAnthropicChatStreamlyWithSenderRejectsStreamError(t *testing.T) {
}
func TestAnthropicListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var calls int
srv := newAnthropicServer(t, "/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
@@ -441,6 +451,7 @@ func TestAnthropicListModelsAndCheckConnection(t *testing.T) {
}
func TestAnthropicListModelsRejectsProviderError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAnthropicServer(t, "/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusForbidden)
@@ -466,6 +477,7 @@ func TestAnthropicFactoryRegistration(t *testing.T) {
}
func TestAnthropicUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newAnthropicForTest("http://unused")
apiKey := "test-key"
diff --git a/internal/entity/models/astraflow.go b/internal/entity/models/astraflow.go
index 7cceac6c10..c144ecfbb2 100644
--- a/internal/entity/models/astraflow.go
+++ b/internal/entity/models/astraflow.go
@@ -54,7 +54,7 @@ func NewAstraflowModel(baseURL map[string]string, urlSuffix URLSuffix) *Astraflo
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/astraflow_test.go b/internal/entity/models/astraflow_test.go
index 3a41ac73f4..e7b275d9c9 100644
--- a/internal/entity/models/astraflow_test.go
+++ b/internal/entity/models/astraflow_test.go
@@ -92,6 +92,7 @@ func TestAstraflowFactory(t *testing.T) {
}
func TestAstraflowChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "claude-opus-4-7" {
@@ -140,6 +141,7 @@ func TestAstraflowChatHappyPath(t *testing.T) {
}
func TestAstraflowChatNoReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -168,6 +170,7 @@ func TestAstraflowChatNoReasoning(t *testing.T) {
}
func TestAstraflowChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newAstraflowForTest("http://unused").ChatWithMessages(
ctx,
@@ -180,6 +183,7 @@ func TestAstraflowChatRequiresAPIKey(t *testing.T) {
}
func TestAstraflowChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newAstraflowForTest("http://unused").ChatWithMessages(
@@ -190,6 +194,7 @@ func TestAstraflowChatRequiresMessages(t *testing.T) {
}
func TestAstraflowChatPropagatesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -209,6 +214,7 @@ func TestAstraflowChatPropagatesHTTPError(t *testing.T) {
}
func TestAstraflowStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowSSEServer(t, "/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -249,6 +255,7 @@ func TestAstraflowStreamHappyPath(t *testing.T) {
}
func TestAstraflowStreamSplitsReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowSSEServer(t, "/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -290,6 +297,7 @@ func TestAstraflowStreamSplitsReasoning(t *testing.T) {
}
func TestAstraflowStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
stream := false
@@ -307,6 +315,7 @@ func TestAstraflowStreamRejectsExplicitFalse(t *testing.T) {
}
func TestAstraflowStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newAstraflowForTest("http://unused").ChatStreamlyWithSender(
@@ -320,6 +329,7 @@ func TestAstraflowStreamRequiresSender(t *testing.T) {
}
func TestAstraflowStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowSSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"half"}}]}`+"\n",
@@ -339,6 +349,7 @@ func TestAstraflowStreamFailsWithoutTerminal(t *testing.T) {
}
func TestAstraflowStreamRejectsMalformedFrame(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowSSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+
@@ -359,6 +370,7 @@ func TestAstraflowStreamRejectsMalformedFrame(t *testing.T) {
}
func TestAstraflowStreamSurfacesUpstreamError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowSSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+
@@ -382,6 +394,7 @@ func TestAstraflowStreamSurfacesUpstreamError(t *testing.T) {
}
func TestAstraflowListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -406,6 +419,7 @@ func TestAstraflowListModelsHappyPath(t *testing.T) {
}
func TestAstraflowListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newAstraflowForTest("http://unused").ListModels(ctx, &APIConfig{})
if err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -414,6 +428,7 @@ func TestAstraflowListModelsRequiresAPIKey(t *testing.T) {
}
func TestAstraflowCheckConnectionDelegatesToListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -429,6 +444,7 @@ func TestAstraflowCheckConnectionDelegatesToListModels(t *testing.T) {
}
func TestAstraflowCheckConnectionPropagatesError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAstraflowServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -444,6 +460,7 @@ func TestAstraflowCheckConnectionPropagatesError(t *testing.T) {
}
func TestAstraflowBaseURLForRegionUnknown(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newAstraflowForTest("http://unused")
apiKey := "test-key"
@@ -455,6 +472,7 @@ func TestAstraflowBaseURLForRegionUnknown(t *testing.T) {
}
func TestAstraflowEmbedReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Embed IS implemented (not a stub). It should NOT be blocked by APIConfigCheck.
// With empty input texts it short-circuits to empty result (no error).
@@ -466,6 +484,7 @@ func TestAstraflowEmbedReturnsNoSuchMethod(t *testing.T) {
}
func TestAstraflowAudioOCRReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newAstraflowForTest("http://unused")
model := "x"
diff --git a/internal/entity/models/avian.go b/internal/entity/models/avian.go
index edc1f9ff1e..fb67eec171 100644
--- a/internal/entity/models/avian.go
+++ b/internal/entity/models/avian.go
@@ -40,7 +40,7 @@ func NewAvianModel(baseURL map[string]string, urlSuffix URLSuffix) *AvianModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/avian_test.go b/internal/entity/models/avian_test.go
index cc1302a17a..607bf45c9f 100644
--- a/internal/entity/models/avian_test.go
+++ b/internal/entity/models/avian_test.go
@@ -79,6 +79,7 @@ func TestAvianFactory(t *testing.T) {
}
func TestAvianChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/v1/chat/completions" {
@@ -138,6 +139,7 @@ func TestAvianChatHappyPath(t *testing.T) {
}
func TestAvianChatFallsBackToReasoningField(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -169,6 +171,7 @@ func TestAvianChatFallsBackToReasoningField(t *testing.T) {
}
func TestAvianChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newAvianForTest("http://unused").ChatWithMessages(
ctx,
@@ -184,6 +187,7 @@ func TestAvianChatRequiresAPIKey(t *testing.T) {
}
func TestAvianChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newAvianForTest("http://unused").ChatWithMessages(
@@ -200,6 +204,7 @@ func TestAvianChatRequiresMessages(t *testing.T) {
}
func TestAvianChatPropagatesUpstreamErrorStatus(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -222,6 +227,7 @@ func TestAvianChatPropagatesUpstreamErrorStatus(t *testing.T) {
}
func TestAvianStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAvianServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/v1/chat/completions" {
@@ -277,6 +283,7 @@ func TestAvianStreamHappyPath(t *testing.T) {
}
func TestAvianStreamRejectsFalseStreamConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
stream := false
@@ -295,6 +302,7 @@ func TestAvianStreamRejectsFalseStreamConfig(t *testing.T) {
}
func TestAvianStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newAvianForTest("http://unused").ChatStreamlyWithSender(
@@ -312,6 +320,7 @@ func TestAvianStreamRequiresSender(t *testing.T) {
}
func TestAvianListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newAvianServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/v1/models" {
@@ -336,6 +345,7 @@ func TestAvianListModelsAndCheckConnection(t *testing.T) {
}
func TestAvianMissingBaseURLFailsClearly(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
a := NewAvianModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"})
apiKey := "test-key"
@@ -353,6 +363,7 @@ func TestAvianMissingBaseURLFailsClearly(t *testing.T) {
}
func TestAvianUnsupportedMethodsReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
a := newAvianForTest("http://unused")
model := "deepseek/deepseek-v3.2"
diff --git a/internal/entity/models/azure_openai.go b/internal/entity/models/azure_openai.go
index 55c5febcb5..44b355e39d 100644
--- a/internal/entity/models/azure_openai.go
+++ b/internal/entity/models/azure_openai.go
@@ -41,7 +41,7 @@ func NewAzureOpenAIModel(baseURL map[string]string, urlSuffix URLSuffix) *AzureO
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/baichuan.go b/internal/entity/models/baichuan.go
index 8f1e368d99..217752d572 100644
--- a/internal/entity/models/baichuan.go
+++ b/internal/entity/models/baichuan.go
@@ -36,7 +36,7 @@ func NewBaichuanModel(baseURL map[string]string, urlSuffix URLSuffix) *BaichuanM
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/baidu.go b/internal/entity/models/baidu.go
index 8a31d77bf6..a6dcb178df 100644
--- a/internal/entity/models/baidu.go
+++ b/internal/entity/models/baidu.go
@@ -41,7 +41,7 @@ func NewBaiduModel(baseURL map[string]string, urlSuffix URLSuffix) *BaiduModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/base_model.go b/internal/entity/models/base_model.go
index ea154153f7..0cd4b888a4 100644
--- a/internal/entity/models/base_model.go
+++ b/internal/entity/models/base_model.go
@@ -26,6 +26,7 @@ import (
"net/http"
"ragflow/internal/common"
"ragflow/internal/engine/clickhouse"
+ "ragflow/internal/utility"
"sort"
"strings"
"time"
@@ -362,7 +363,17 @@ func ParseListModel(modelList ModelList) []ListModelResponse {
}
// NewDriverHTTPClient returns an *http.Client with the standard connection-pool
-func NewDriverHTTPClient() *http.Client {
+// settings and an SSRF guard wired into its Transport.
+//
+// allowPrivate selects the guard strictness:
+// - false (cloud-hosted drivers): every request is validated with
+// utility.AssertURLSafe — scheme + host must be present and every resolved
+// IP must be globally routable (private/loopback/link-local/metadata are
+// rejected). This is the default and closes the go/request-forgery sink.
+// - true (local-inference drivers): requests are validated with
+// utility.AssertURLSchemeSafe — only the scheme and a non-empty host are
+// enforced, so self-hosted backends on private networks or loopback work.
+func NewDriverHTTPClient(allowPrivate bool) *http.Client {
var t *http.Transport
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
t = dt.Clone()
@@ -375,7 +386,41 @@ func NewDriverHTTPClient() *http.Client {
t.DisableCompression = false
t.ResponseHeaderTimeout = 2 * 60 * time.Second
t.TLSHandshakeTimeout = 30 * time.Second
- return &http.Client{Transport: t}
+
+ var rt http.RoundTripper = t
+ if allowPrivate {
+ rt = &schemeSafeTransport{base: rt}
+ } else {
+ rt = &strictSSRFTransport{base: rt}
+ }
+ return &http.Client{Transport: rt}
+}
+
+// schemeSafeTransport wraps an http.RoundTripper so every outgoing request is
+// validated by the lenient SSRF guard (http/https scheme + non-empty host).
+// Private and loopback hosts are permitted. Used only by local-inference
+// drivers that may target a user's own network.
+type schemeSafeTransport struct{ base http.RoundTripper }
+
+func (t *schemeSafeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if err := utility.AssertURLSchemeSafe(req.URL.String()); err != nil {
+ return nil, err
+ }
+ return t.base.RoundTrip(req)
+}
+
+// strictSSRFTransport wraps an http.RoundTripper so every outgoing request is
+// validated by the strict SSRF guard (scheme + host + globally routable IP).
+// This is the default for cloud-hosted model drivers and closes the
+// go/request-forgery data flow: the user-controllable BaseURL cannot be made to
+// point at private hosts, loopback, link-local, or cloud metadata endpoints.
+type strictSSRFTransport struct{ base http.RoundTripper }
+
+func (t *strictSSRFTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if _, _, err := utility.AssertURLSafe(req.URL.String()); err != nil {
+ return nil, err
+ }
+ return t.base.RoundTrip(req)
}
// PostJSONRequest marshals body to JSON, creates a POST request to url
diff --git a/internal/entity/models/bedrock.go b/internal/entity/models/bedrock.go
index 6b42401368..d46ac59723 100644
--- a/internal/entity/models/bedrock.go
+++ b/internal/entity/models/bedrock.go
@@ -105,7 +105,7 @@ func NewBedrockModel(baseURL map[string]string, urlSuffix URLSuffix) *BedrockMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/bedrock_test.go b/internal/entity/models/bedrock_test.go
index 9687e1dec0..a1e333cc47 100644
--- a/internal/entity/models/bedrock_test.go
+++ b/internal/entity/models/bedrock_test.go
@@ -327,6 +327,7 @@ func newBedrockServer(t *testing.T, wantMethod, wantPath string, handler http.Ha
}
func TestBedrockChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodPost,
"/model/anthropic.claude-3-haiku-20240307-v1:0/converse",
@@ -369,6 +370,7 @@ func TestBedrockChatHappyPath(t *testing.T) {
}
func TestBedrockChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
_, err := m.ChatWithMessages(ctx, "m", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil)
@@ -378,6 +380,7 @@ func TestBedrockChatRequiresAPIKey(t *testing.T) {
}
func TestBedrockChatRequiresModelID(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
key := validBedrockKey()
@@ -389,6 +392,7 @@ func TestBedrockChatRequiresModelID(t *testing.T) {
}
func TestBedrockChatPropagatesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodPost,
"/model/m/converse",
@@ -412,6 +416,7 @@ func TestBedrockChatPropagatesHTTPError(t *testing.T) {
}
func TestBedrockListModelsParsesCatalog(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodGet,
"/foundation-models",
@@ -450,6 +455,7 @@ func TestBedrockListModelsParsesCatalog(t *testing.T) {
}
func TestBedrockCheckConnectionDelegates(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodGet,
"/foundation-models",
@@ -503,6 +509,7 @@ func encodeBedrockEventFrames(t *testing.T, events []struct {
}
func TestBedrockStreamDecodesContentDeltas(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
frames := encodeBedrockEventFrames(t, []struct {
eventType string
@@ -553,6 +560,7 @@ func TestBedrockStreamDecodesContentDeltas(t *testing.T) {
}
func TestBedrockStreamSurfacesException(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
frames := encodeBedrockEventFrames(t, []struct {
eventType string
@@ -586,6 +594,7 @@ func TestBedrockStreamSurfacesException(t *testing.T) {
}
func TestBedrockStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Connection closed cleanly after a delta but before messageStop.
// This used to be silently treated as success, masking truncated
@@ -618,6 +627,7 @@ func TestBedrockStreamFailsWithoutTerminal(t *testing.T) {
}
func TestBedrockStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
key := validBedrockKey()
@@ -634,6 +644,7 @@ func TestBedrockStreamRejectsExplicitFalse(t *testing.T) {
}
func TestBedrockStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
key := validBedrockKey()
@@ -659,6 +670,7 @@ func TestLookupBedrockEventHeader(t *testing.T) {
}
func TestBedrockTitanEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seenInputs []string
srv := newBedrockServer(t, http.MethodPost,
@@ -702,6 +714,7 @@ func TestBedrockTitanEmbedHappyPath(t *testing.T) {
}
func TestBedrockTitanV1OmitsDimension(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodPost,
"/model/amazon.titan-embed-text-v1/invoke",
@@ -723,6 +736,7 @@ func TestBedrockTitanV1OmitsDimension(t *testing.T) {
}
func TestBedrockCohereEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodPost,
"/model/cohere.embed-english-v3/invoke",
@@ -760,6 +774,7 @@ func TestBedrockCohereEmbedHappyPath(t *testing.T) {
}
func TestBedrockCohereV4ForwardsDimensionAndParsesTypedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodPost,
"/model/cohere.embed-v4:0/invoke",
@@ -790,6 +805,7 @@ func TestBedrockCohereV4ForwardsDimensionAndParsesTypedResponse(t *testing.T) {
}
func TestBedrockEmbedShortCircuitsEmptyInput(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
got, err := m.Embed(ctx, nil, nil, nil, nil, nil)
@@ -802,6 +818,7 @@ func TestBedrockEmbedShortCircuitsEmptyInput(t *testing.T) {
}
func TestBedrockEmbedRequiresAPIKeyAndModel(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
model := "x"
@@ -816,6 +833,7 @@ func TestBedrockEmbedRequiresAPIKeyAndModel(t *testing.T) {
}
func TestBedrockEmbedRejectsUnsupportedModel(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
key := validBedrockKey()
@@ -826,6 +844,7 @@ func TestBedrockEmbedRejectsUnsupportedModel(t *testing.T) {
}
func TestBedrockEmbedPropagatesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newBedrockServer(t, http.MethodPost,
"/model/amazon.titan-embed-text-v2:0/invoke",
@@ -844,6 +863,7 @@ func TestBedrockEmbedPropagatesHTTPError(t *testing.T) {
}
func TestBedrockRerankReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
model := "x"
@@ -853,6 +873,7 @@ func TestBedrockRerankReturnsNoSuchMethod(t *testing.T) {
}
func TestBedrockBalanceReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
if _, err := m.Balance(ctx, &APIConfig{}); err == nil || !strings.Contains(err.Error(), "no such method") {
@@ -861,6 +882,7 @@ func TestBedrockBalanceReturnsNoSuchMethod(t *testing.T) {
}
func TestBedrockAudioOCRReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newBedrockForTest("http://unused")
model := "x"
diff --git a/internal/entity/models/chat_usage_test.go b/internal/entity/models/chat_usage_test.go
index b07a4094b5..26e51fdf58 100644
--- a/internal/entity/models/chat_usage_test.go
+++ b/internal/entity/models/chat_usage_test.go
@@ -9,6 +9,7 @@ import (
)
func TestProviderChatUsage(t *testing.T) {
+ withSSRFBypass(t)
type providerCase struct {
name string
new func(string) ModelDriver
diff --git a/internal/entity/models/cohere.go b/internal/entity/models/cohere.go
index 9cc5e47706..5e872bbede 100644
--- a/internal/entity/models/cohere.go
+++ b/internal/entity/models/cohere.go
@@ -44,7 +44,7 @@ func NewCoHereModel(baseURL map[string]string, urlSuffix URLSuffix) *CoHereModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/cohere_test.go b/internal/entity/models/cohere_test.go
index 81ea4dfd26..97facea580 100644
--- a/internal/entity/models/cohere_test.go
+++ b/internal/entity/models/cohere_test.go
@@ -18,6 +18,7 @@ func newCohereForTest(baseURL string) *CoHereModel {
}
func TestCohereChatAllowsToolCallOnlyResponse(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/chat" {
t.Errorf("path=%s", r.URL.Path)
@@ -70,6 +71,7 @@ func TestCohereChatAllowsToolCallOnlyResponse(t *testing.T) {
}
func TestCohereStreamRecordsDeltaUsage(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/chat" {
t.Errorf("path=%s", r.URL.Path)
diff --git a/internal/entity/models/cometapi.go b/internal/entity/models/cometapi.go
index dbef044951..2c2b9a5a83 100644
--- a/internal/entity/models/cometapi.go
+++ b/internal/entity/models/cometapi.go
@@ -43,7 +43,7 @@ func NewCometAPIModel(baseURL map[string]string, urlSuffix URLSuffix) *CometAPIM
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/cometapi_test.go b/internal/entity/models/cometapi_test.go
index 1a93719b17..7403a28dfd 100644
--- a/internal/entity/models/cometapi_test.go
+++ b/internal/entity/models/cometapi_test.go
@@ -77,6 +77,7 @@ func TestCometAPIFactoryRoute(t *testing.T) {
}
func TestCometAPIChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "gpt-5" {
@@ -115,6 +116,7 @@ func TestCometAPIChatHappyPath(t *testing.T) {
}
func TestCometAPIChatPropagatesConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["max_tokens"] != float64(64) {
@@ -152,6 +154,7 @@ func TestCometAPIChatPropagatesConfig(t *testing.T) {
}
func TestCometAPIChatReturnsReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -174,6 +177,7 @@ func TestCometAPIChatReturnsReasoningContent(t *testing.T) {
}
func TestCometAPIChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
_, err := m.ChatWithMessages(ctx, "gpt-5", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil)
@@ -188,6 +192,7 @@ func TestCometAPIChatRequiresAPIKey(t *testing.T) {
}
func TestCometAPIChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
apiKey := "test-key"
@@ -202,6 +207,7 @@ func TestCometAPIChatRequiresModelName(t *testing.T) {
}
func TestCometAPIChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
apiKey := "test-key"
@@ -212,6 +218,7 @@ func TestCometAPIChatRequiresMessages(t *testing.T) {
}
func TestCometAPIChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -228,6 +235,7 @@ func TestCometAPIChatRejectsHTTPError(t *testing.T) {
}
func TestCometAPIChatFallsBackToDefaultOnEmptyRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Empty *Region pointer must fall back to the "default" entry, not
// be treated as an explicit "" region (which would miss the lookup).
@@ -250,6 +258,7 @@ func TestCometAPIChatFallsBackToDefaultOnEmptyRegion(t *testing.T) {
}
func TestCometAPIListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/api/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "x"}}})
@@ -265,6 +274,7 @@ func TestCometAPIListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) {
}
func TestCometAPIStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
apiKey := "test-key"
@@ -277,6 +287,7 @@ func TestCometAPIStreamRequiresSender(t *testing.T) {
}
func TestCometAPIChatRejectsUnknownRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
apiKey := "test-key"
@@ -289,6 +300,7 @@ func TestCometAPIChatRejectsUnknownRegion(t *testing.T) {
}
func TestCometAPIBaseURLNormalizesSlashes(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
tests := []struct {
name string
@@ -359,6 +371,7 @@ func TestCometAPIBaseURLNormalizesSlashes(t *testing.T) {
}
func TestCometAPIStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["stream"] != true {
@@ -407,6 +420,7 @@ func TestCometAPIStreamHappyPath(t *testing.T) {
}
func TestCometAPIStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
apiKey := "test-key"
@@ -424,6 +438,7 @@ func TestCometAPIStreamRejectsExplicitFalse(t *testing.T) {
}
func TestCometAPIStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Body closes before [DONE] or a finish_reason -> driver must complain
// instead of pretending the stream finished cleanly.
@@ -446,6 +461,7 @@ func TestCometAPIStreamFailsWithoutTerminal(t *testing.T) {
}
func TestCometAPIListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/api/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -470,6 +486,7 @@ func TestCometAPIListModelsHappyPath(t *testing.T) {
}
func TestCometAPIListModelsAllowsNilAPIConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/api/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "gpt-5"}}})
@@ -487,6 +504,7 @@ func TestCometAPIListModelsAllowsNilAPIConfig(t *testing.T) {
}
func TestCometAPICheckConnectionDelegatesToBalance(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// 200 -> CheckConnection succeeds; 401 -> CheckConnection propagates.
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -518,6 +536,7 @@ func TestCometAPICheckConnectionDelegatesToBalance(t *testing.T) {
}
func TestCometAPIBalanceHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/quota" {
@@ -551,6 +570,7 @@ func TestCometAPIBalanceHappyPath(t *testing.T) {
}
func TestCometAPIBalanceRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
_, err := m.Balance(ctx, &APIConfig{})
@@ -560,6 +580,7 @@ func TestCometAPIBalanceRequiresAPIKey(t *testing.T) {
}
func TestCometAPIBalanceRequiresConfiguredURL(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
m.baseModel.URLSuffix.Balance = ""
@@ -571,6 +592,7 @@ func TestCometAPIBalanceRequiresConfiguredURL(t *testing.T) {
}
func TestCometAPIRerankReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
q := "gpt-5"
@@ -581,6 +603,7 @@ func TestCometAPIRerankReturnsNoSuchMethod(t *testing.T) {
}
func TestCometAPIEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "text-embedding-3-small" {
@@ -619,6 +642,7 @@ func TestCometAPIEmbedHappyPath(t *testing.T) {
}
func TestCometAPIEmbedReordersByIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Upstream returns the three vectors in shuffled order. The driver
// must reorder them so the slot at position i corresponds to input i.
@@ -648,6 +672,7 @@ func TestCometAPIEmbedReordersByIndex(t *testing.T) {
}
func TestCometAPIEmbedEmptyInputShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Empty input must NOT make an HTTP call; the test fails the request
// rather than the assertion if it does.
@@ -670,6 +695,7 @@ func TestCometAPIEmbedEmptyInputShortCircuits(t *testing.T) {
}
func TestCometAPIEmbedRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
model := "text-embedding-3-small"
@@ -680,6 +706,7 @@ func TestCometAPIEmbedRequiresAPIKey(t *testing.T) {
}
func TestCometAPIEmbedRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newCometAPIForTest("http://unused")
apiKey := "test-key"
@@ -695,6 +722,7 @@ func TestCometAPIEmbedRequiresModelName(t *testing.T) {
}
func TestCometAPIEmbedRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// A malformed upstream that repeats data[*].index would silently
// overwrite the earlier vector; the driver must fail loudly instead.
@@ -718,6 +746,7 @@ func TestCometAPIEmbedRejectsDuplicateIndex(t *testing.T) {
}
func TestCometAPIEmbedRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -738,6 +767,7 @@ func TestCometAPIEmbedRejectsOutOfRangeIndex(t *testing.T) {
}
func TestCometAPIEmbedRejectsMissingSlot(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Upstream returns only one of the two requested embeddings.
srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
@@ -759,6 +789,7 @@ func TestCometAPIEmbedRejectsMissingSlot(t *testing.T) {
}
func TestCometAPIEmbedRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newCometAPIServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
diff --git a/internal/entity/models/deepinfra.go b/internal/entity/models/deepinfra.go
index ea1f3f6315..4f3889cd2d 100644
--- a/internal/entity/models/deepinfra.go
+++ b/internal/entity/models/deepinfra.go
@@ -42,7 +42,7 @@ func NewDeepInfraModel(baseURL map[string]string, urlSuffix URLSuffix) *DeepInfr
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/deepinfra_test.go b/internal/entity/models/deepinfra_test.go
index 354d53443c..177a798a1f 100644
--- a/internal/entity/models/deepinfra_test.go
+++ b/internal/entity/models/deepinfra_test.go
@@ -23,6 +23,7 @@ func newDeepInfraForTest(baseURL string) *DeepInfraModel {
// TestDeepInfraRerankHappyPath verifies request shape and score mapping.
func TestDeepInfraRerankHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
const modelPath = "/v1/inference/Qwen/Qwen3-Reranker-4B"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -79,6 +80,7 @@ func TestDeepInfraRerankHappyPath(t *testing.T) {
// TestDeepInfraRerankNoTopNLimit returns every scored document when TopN is unset.
func TestDeepInfraRerankNoTopNLimit(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -110,6 +112,7 @@ func TestDeepInfraRerankNoTopNLimit(t *testing.T) {
// TestDeepInfraRerankEmptyDocuments returns an empty result without calling the API.
func TestDeepInfraRerankEmptyDocuments(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
model := "Qwen/Qwen3-Reranker-4B"
@@ -124,6 +127,7 @@ func TestDeepInfraRerankEmptyDocuments(t *testing.T) {
// TestDeepInfraRerankRequiresAPIKey rejects requests without an API key.
func TestDeepInfraRerankRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := "Qwen/Qwen3-Reranker-4B"
_, err := newDeepInfraForTest("http://unused").Rerank(ctx, &model, "q", []string{"a"}, &APIConfig{}, nil, nil)
@@ -134,6 +138,7 @@ func TestDeepInfraRerankRequiresAPIKey(t *testing.T) {
// TestDeepInfraRerankRejectsScoreCountMismatch errors when scores length mismatches documents.
func TestDeepInfraRerankRejectsScoreCountMismatch(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"scores": []float64{0.5}})
diff --git a/internal/entity/models/deepseek.go b/internal/entity/models/deepseek.go
index bb8b0dde14..0b332713b1 100644
--- a/internal/entity/models/deepseek.go
+++ b/internal/entity/models/deepseek.go
@@ -39,7 +39,7 @@ func NewDeepSeekModel(baseURL map[string]string, urlSuffix URLSuffix) *DeepSeekM
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/deepseek_test.go b/internal/entity/models/deepseek_test.go
index bab79a2ea7..34617cfbcf 100644
--- a/internal/entity/models/deepseek_test.go
+++ b/internal/entity/models/deepseek_test.go
@@ -18,6 +18,7 @@ func newDeepSeekForTest(baseURL string) *DeepSeekModel {
}
func TestDeepSeekChatWithMessagesSupportsToolCalls(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var requestBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -108,6 +109,7 @@ func TestDeepSeekChatWithMessagesSupportsToolCalls(t *testing.T) {
}
func TestDeepSeekChatWithMessagesForwardsToolHistory(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var requestBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/entity/models/fishaudio.go b/internal/entity/models/fishaudio.go
index 3710da496d..a80d08e635 100644
--- a/internal/entity/models/fishaudio.go
+++ b/internal/entity/models/fishaudio.go
@@ -41,7 +41,7 @@ func NewFishAudioModel(baseURL map[string]string, urlSuffix URLSuffix) *FishAudi
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/fishaudio_test.go b/internal/entity/models/fishaudio_test.go
index 18763327b7..a5da1e15a5 100644
--- a/internal/entity/models/fishaudio_test.go
+++ b/internal/entity/models/fishaudio_test.go
@@ -28,6 +28,7 @@ func newFishAudioForListModelsTest(baseURL string) *FishAudioModel {
}
func TestFishAudioListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -62,6 +63,7 @@ func TestFishAudioListModels(t *testing.T) {
}
func TestFishAudioListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
if _, err := newFishAudioForListModelsTest("http://unused").ListModels(ctx, &APIConfig{}); err == nil {
t.Fatal("ListModels: expected error for missing api key, got nil")
@@ -69,6 +71,7 @@ func TestFishAudioListModelsRequiresAPIKey(t *testing.T) {
}
func TestFishAudioListModelsRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
diff --git a/internal/entity/models/funasr.go b/internal/entity/models/funasr.go
index e8929061ca..c4b7cbfc86 100644
--- a/internal/entity/models/funasr.go
+++ b/internal/entity/models/funasr.go
@@ -41,7 +41,7 @@ func NewFunASRModel(baseURL map[string]string, urlSuffix URLSuffix) *FunASR {
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/funasr_test.go b/internal/entity/models/funasr_test.go
index e37975b688..9853242f6f 100644
--- a/internal/entity/models/funasr_test.go
+++ b/internal/entity/models/funasr_test.go
@@ -32,6 +32,7 @@ func writeFunASRTestAudio(t *testing.T) string {
}
func TestFunASRTranscribeAudioWithoutAPIKey(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method=%s, want POST", r.Method)
@@ -67,6 +68,7 @@ func TestFunASRTranscribeAudioWithoutAPIKey(t *testing.T) {
}
func TestFunASRTranscribeAudioRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
file := writeFunASRTestAudio(t)
apiKey := "test-key"
blankModelName := " "
@@ -89,6 +91,7 @@ func TestFunASRTranscribeAudioRequiresModelName(t *testing.T) {
}
func TestFunASRListModelsWithoutAPIKey(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method=%s, want GET", r.Method)
@@ -113,6 +116,7 @@ func TestFunASRListModelsWithoutAPIKey(t *testing.T) {
}
func TestFunASRListModelsSendsAuthWhenProvided(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
t.Errorf("Authorization=%q, want Bearer secret", got)
diff --git a/internal/entity/models/futurmix.go b/internal/entity/models/futurmix.go
index 721f0468a6..cb81d8c4dd 100644
--- a/internal/entity/models/futurmix.go
+++ b/internal/entity/models/futurmix.go
@@ -38,7 +38,7 @@ func NewFuturMixModel(baseURL map[string]string, urlSuffix URLSuffix) *FuturMixM
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/gitee.go b/internal/entity/models/gitee.go
index c5eb2883ca..8ba04c792c 100644
--- a/internal/entity/models/gitee.go
+++ b/internal/entity/models/gitee.go
@@ -40,7 +40,7 @@ func NewGiteeModel(baseURL map[string]string, urlSuffix URLSuffix) *GiteeModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/gitee_test.go b/internal/entity/models/gitee_test.go
index 2764a29666..280fefcae4 100644
--- a/internal/entity/models/gitee_test.go
+++ b/internal/entity/models/gitee_test.go
@@ -51,6 +51,7 @@ func newGiteeForChatTest(baseURL string) *GiteeModel {
}
func TestGiteeStreamAcceptsTerminalWithoutDelta(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method=%s, want POST", r.Method)
@@ -110,6 +111,7 @@ func deepSeekAliasModelsForTest(t *testing.T) map[string]Model {
}
func TestGiteeListModelsMapsAllDeepSeekAliasesToModelMetadata(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
initProviderManagerWithGiteeForTest(t)
aliasModels := deepSeekAliasModelsForTest(t)
@@ -187,6 +189,7 @@ func TestGiteeListModelsMapsAllDeepSeekAliasesToModelMetadata(t *testing.T) {
}
func TestGiteeListModelsKeepsOwnedBySuffixAfterAliasMetadataLookup(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
initProviderManagerWithGiteeForTest(t)
@@ -218,6 +221,7 @@ func TestGiteeListModelsKeepsOwnedBySuffixAfterAliasMetadataLookup(t *testing.T)
}
func TestGiteeListModelsIntegration(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
if common.GetEnv(common.EnvGiteeListModelsIntegration) != "1" {
t.Skip("set GITEE_LIST_MODELS_INTEGRATION=1 to call the real Gitee models endpoint")
diff --git a/internal/entity/models/google_test.go b/internal/entity/models/google_test.go
index 2a4af41e37..1018aaa6c1 100644
--- a/internal/entity/models/google_test.go
+++ b/internal/entity/models/google_test.go
@@ -28,6 +28,7 @@ func withGoogleListModelsStub(t *testing.T, fn func(context.Context, *genai.Clie
}
func TestGoogleModelListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := &GoogleModel{}
cases := []struct {
@@ -83,6 +84,7 @@ func TestGoogleModelListModelsRequiresAPIKey(t *testing.T) {
}
func TestGoogleModelListModelsReturnsModelNames(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := &GoogleModel{}
apiKey := "test-api-key"
@@ -106,6 +108,7 @@ func TestGoogleModelListModelsReturnsModelNames(t *testing.T) {
}
func TestGoogleModelCheckConnectionUsesListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
customBaseURL := "https://check-connection.example.test/google"
model := NewGoogleModel(map[string]string{"default": customBaseURL}, URLSuffix{})
@@ -132,6 +135,7 @@ func TestGoogleModelCheckConnectionUsesListModels(t *testing.T) {
}
func TestGoogleModelCheckConnectionRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := &GoogleModel{}
calls := 0
@@ -184,6 +188,7 @@ func TestGoogleModelCheckConnectionRequiresAPIKey(t *testing.T) {
}
func TestGoogleModelCheckConnectionReturnsListModelsError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := &GoogleModel{}
apiKey := "test-api-key"
@@ -200,6 +205,7 @@ func TestGoogleModelCheckConnectionReturnsListModelsError(t *testing.T) {
}
func TestGoogleModelChatStreamlyRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := &GoogleModel{}
messages := []Message{{Role: "user", Content: "hello"}}
@@ -230,6 +236,7 @@ func TestGoogleModelChatStreamlyRequiresAPIKey(t *testing.T) {
}
func TestGoogleModelChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := &GoogleModel{}
apiKey := "test-api-key"
@@ -313,6 +320,7 @@ func TestGoogleModelNewInstancePreservesCustomBaseURL(t *testing.T) {
}
func TestGoogleModelListModelsPassesBaseURL(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-api-key"
cases := []struct {
name string
diff --git a/internal/entity/models/gpustack.go b/internal/entity/models/gpustack.go
index 95460b0e2f..fb87f4afde 100644
--- a/internal/entity/models/gpustack.go
+++ b/internal/entity/models/gpustack.go
@@ -38,7 +38,7 @@ func NewGPUStackModel(baseURL map[string]string, urlSuffix URLSuffix) *GPUStackM
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/gpustack_test.go b/internal/entity/models/gpustack_test.go
index 4c3675a5f6..a8b32d21a4 100644
--- a/internal/entity/models/gpustack_test.go
+++ b/internal/entity/models/gpustack_test.go
@@ -96,6 +96,7 @@ func TestGPUStackFactory(t *testing.T) {
}
func TestGPUStackChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "qwen3-8b" {
t.Errorf("model=%v", body["model"])
@@ -131,6 +132,7 @@ func TestGPUStackChatHappyPath(t *testing.T) {
}
func TestGPUStackChatExtractsReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"choices": []map[string]interface{}{{
@@ -164,6 +166,7 @@ func TestGPUStackChatExtractsReasoningContent(t *testing.T) {
}
func TestGPUStackChatForwardsDocumentedFields(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
for _, k := range []string{"model", "messages", "stream", "max_tokens", "temperature", "top_p", "stop"} {
if _, present := body[k]; !present {
@@ -198,6 +201,7 @@ func TestGPUStackChatForwardsDocumentedFields(t *testing.T) {
}
func TestGPUStackChatAllowsEmptyAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newGPUStackForTest("http://unused").ChatWithMessages(
ctx,
@@ -211,6 +215,7 @@ func TestGPUStackChatAllowsEmptyAPIKey(t *testing.T) {
}
func TestGPUStackChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
ctx := t.Context()
_, err := newGPUStackForTest("http://unused").ChatWithMessages(
@@ -225,6 +230,7 @@ func TestGPUStackChatRequiresModelName(t *testing.T) {
}
func TestGPUStackChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
ctx := t.Context()
_, err := newGPUStackForTest("http://unused").ChatWithMessages(
@@ -237,6 +243,7 @@ func TestGPUStackChatRequiresMessages(t *testing.T) {
}
func TestGPUStackChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, "/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
@@ -257,6 +264,7 @@ func TestGPUStackChatRejectsHTTPError(t *testing.T) {
}
func TestGPUStackChatRequiresBaseURL(t *testing.T) {
+ withSSRFBypass(t)
model := NewGPUStackModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"})
apiKey := "test-key"
ctx := t.Context()
@@ -272,6 +280,7 @@ func TestGPUStackChatRequiresBaseURL(t *testing.T) {
}
func TestGPUStackStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}`+"\n"+
@@ -313,6 +322,7 @@ func TestGPUStackStreamHappyPath(t *testing.T) {
}
func TestGPUStackStreamExtractsReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"reasoning_content":"think "}}]}`+"\n"+
@@ -351,6 +361,7 @@ func TestGPUStackStreamExtractsReasoningContent(t *testing.T) {
}
func TestGPUStackStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
stream := false
ctx := t.Context()
@@ -369,6 +380,7 @@ func TestGPUStackStreamRejectsExplicitFalse(t *testing.T) {
}
func TestGPUStackStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
ctx := t.Context()
err := newGPUStackForTest("http://unused").ChatStreamlyWithSender(
@@ -383,6 +395,7 @@ func TestGPUStackStreamRequiresSender(t *testing.T) {
}
func TestGPUStackStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"half"}}]}`+"\n",
)
@@ -403,6 +416,7 @@ func TestGPUStackStreamFailsWithoutTerminal(t *testing.T) {
}
func TestGPUStackStreamRejectsMalformedFrame(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+
`data: {this is not valid json}`+"\n",
@@ -424,6 +438,7 @@ func TestGPUStackStreamRejectsMalformedFrame(t *testing.T) {
}
func TestGPUStackStreamSurfacesUpstreamError(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+
`data: {"error":{"message":"oom","type":"runtime_error"}}`+"\n",
@@ -445,6 +460,7 @@ func TestGPUStackStreamSurfacesUpstreamError(t *testing.T) {
}
func TestGPUStackListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -480,6 +496,7 @@ func TestGPUStackListModelsHappyPath(t *testing.T) {
}
func TestGPUStackListModelsAllowsEmptyAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newGPUStackForTest("http://unused").ListModels(ctx, &APIConfig{})
if err == nil || strings.Contains(err.Error(), "api key is required") {
@@ -489,6 +506,7 @@ func TestGPUStackListModelsAllowsEmptyAPIKey(t *testing.T) {
// TestGPUStackEmbedHappyPath verifies request shape and dimensions on v1-openai/embeddings.
func TestGPUStackEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "bge-m3" {
t.Errorf("model=%v", body["model"])
@@ -527,6 +545,7 @@ func TestGPUStackEmbedHappyPath(t *testing.T) {
// TestGPUStackEmbedReordersByIndex verifies out-of-order response indices are mapped correctly.
func TestGPUStackEmbedReordersByIndex(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
@@ -555,6 +574,7 @@ func TestGPUStackEmbedReordersByIndex(t *testing.T) {
// TestGPUStackEmbedEmptyInputShortCircuits avoids HTTP when texts is empty.
func TestGPUStackEmbedEmptyInputShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("Embed([]) made an unexpected HTTP call")
w.WriteHeader(http.StatusInternalServerError)
@@ -576,6 +596,7 @@ func TestGPUStackEmbedEmptyInputShortCircuits(t *testing.T) {
// TestGPUStackEmbedRequiresAPIKey rejects requests without an API key.
func TestGPUStackEmbedAllowsEmptyAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := "bge-m3"
_, err := newGPUStackForTest("http://unused").Embed(ctx, &model, []string{"a"}, &APIConfig{}, nil, nil)
@@ -586,6 +607,7 @@ func TestGPUStackEmbedAllowsEmptyAPIKey(t *testing.T) {
// TestGPUStackEmbedRejectsDuplicateIndex errors on duplicate response indices.
func TestGPUStackEmbedRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
@@ -607,6 +629,7 @@ func TestGPUStackEmbedRejectsDuplicateIndex(t *testing.T) {
// TestGPUStackEmbedRejectsOutOfRangeIndex errors when index exceeds input length.
func TestGPUStackEmbedRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
@@ -627,6 +650,7 @@ func TestGPUStackEmbedRejectsOutOfRangeIndex(t *testing.T) {
// TestGPUStackEmbedRejectsMissingIndex errors when index is omitted from response.
func TestGPUStackEmbedRejectsMissingIndex(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
@@ -647,6 +671,7 @@ func TestGPUStackEmbedRejectsMissingIndex(t *testing.T) {
// TestGPUStackEmbedRejectsEmptyVector errors when the API returns a zero-length vector.
func TestGPUStackEmbedRejectsEmptyVector(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
@@ -667,6 +692,7 @@ func TestGPUStackEmbedRejectsEmptyVector(t *testing.T) {
// TestGPUStackEmbedRejectsMissingSlot errors when a response index is never returned.
func TestGPUStackEmbedRejectsMissingSlot(t *testing.T) {
+ withSSRFBypass(t)
srv := newGPUStackServer(t, gpustackEmbeddingsPath, func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
@@ -686,6 +712,7 @@ func TestGPUStackEmbedRejectsMissingSlot(t *testing.T) {
}
func TestGPUStackUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newGPUStackForTest("http://unused")
model := "x"
diff --git a/internal/entity/models/greenpt_test.go b/internal/entity/models/greenpt_test.go
index 63015bec84..c571ab4307 100644
--- a/internal/entity/models/greenpt_test.go
+++ b/internal/entity/models/greenpt_test.go
@@ -26,6 +26,7 @@ import (
)
func TestGreenPTListModelsClassifiesSpeech(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
t.Errorf("unexpected path: %s", r.URL.Path)
@@ -69,6 +70,7 @@ func TestGreenPTListModelsClassifiesSpeech(t *testing.T) {
}
func TestGreenPTTranscribeAudio(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/listen" || r.URL.Query().Get("model") != "green-s" {
t.Errorf("unexpected request URL: %s", r.URL.String())
diff --git a/internal/entity/models/groq.go b/internal/entity/models/groq.go
index 83b8174127..25081df728 100644
--- a/internal/entity/models/groq.go
+++ b/internal/entity/models/groq.go
@@ -41,7 +41,7 @@ func NewGroqModel(baseURL map[string]string, urlSuffix URLSuffix) *GroqModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/groq_test.go b/internal/entity/models/groq_test.go
index 064186416c..a9f931036b 100644
--- a/internal/entity/models/groq_test.go
+++ b/internal/entity/models/groq_test.go
@@ -113,6 +113,7 @@ func TestNewGroqModelHandlesCustomDefaultTransport(t *testing.T) {
}
func TestGroqChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodPost {
t.Errorf("method=%s", r.Method)
@@ -181,6 +182,7 @@ func TestGroqChatHappyPath(t *testing.T) {
}
func TestGroqChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newGroqForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -190,6 +192,7 @@ func TestGroqChatRequiresModelName(t *testing.T) {
}
func TestGroqChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newGroqForTest("http://unused").ChatWithMessages(ctx, "llama-3.3-70b-versatile", nil, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -199,6 +202,7 @@ func TestGroqChatRequiresMessages(t *testing.T) {
}
func TestGroqChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"unauthorized"}}`))
@@ -221,6 +225,7 @@ func TestGroqChatRejectsHTTPError(t *testing.T) {
}
func TestGroqStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path=%s", r.URL.Path)
@@ -280,6 +285,7 @@ func TestGroqStreamHappyPath(t *testing.T) {
}
func TestGroqStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
stream := false
ctx := t.Context()
@@ -298,6 +304,7 @@ func TestGroqStreamRejectsExplicitFalse(t *testing.T) {
}
func TestGroqStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
ctx := t.Context()
err := newGroqForTest("http://unused").ChatStreamlyWithSender(
@@ -315,6 +322,7 @@ func TestGroqStreamRequiresSender(t *testing.T) {
}
func TestGroqStreamRejectsUnterminatedStream(t *testing.T) {
+ withSSRFBypass(t)
srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"partial"}}]}`+"\n")
@@ -338,6 +346,7 @@ func TestGroqStreamRejectsUnterminatedStream(t *testing.T) {
}
func TestGroqListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -370,6 +379,7 @@ func TestGroqListModelsAndCheckConnection(t *testing.T) {
}
func TestGroqBaseURLTrimsTrailingSlash(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
@@ -397,6 +407,7 @@ func TestGroqBaseURLTrimsTrailingSlash(t *testing.T) {
}
func TestGroqUsesEmptyRegionCustomBaseURL(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newGroqServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
@@ -429,6 +440,7 @@ func TestGroqUsesEmptyRegionCustomBaseURL(t *testing.T) {
}
func TestGroqUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newGroqForTest("http://unused")
if _, err := m.Embed(ctx, nil, nil, nil, nil, nil); err == nil || !strings.Contains(err.Error(), "no such method") {
diff --git a/internal/entity/models/huaweicloud.go b/internal/entity/models/huaweicloud.go
index 2c23a1270b..a032463c45 100644
--- a/internal/entity/models/huaweicloud.go
+++ b/internal/entity/models/huaweicloud.go
@@ -37,7 +37,7 @@ func NewHuaweiCloudModel(baseURL map[string]string, urlSuffix URLSuffix) *Huawei
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/huaweicloud_test.go b/internal/entity/models/huaweicloud_test.go
index 8c4d59cfc8..cc86a37fb0 100644
--- a/internal/entity/models/huaweicloud_test.go
+++ b/internal/entity/models/huaweicloud_test.go
@@ -19,6 +19,7 @@ package models
import "testing"
func TestHuaweiCloudToolCalls(t *testing.T) {
+ withSSRFBypass(t)
newDriver := func(baseURL string) ModelDriver {
return NewHuaweiCloudModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "v2/chat/completions"})
}
diff --git a/internal/entity/models/huggingface.go b/internal/entity/models/huggingface.go
index f5c674989c..bc3d5145be 100644
--- a/internal/entity/models/huggingface.go
+++ b/internal/entity/models/huggingface.go
@@ -37,7 +37,7 @@ func NewHuggingFaceModel(baseURL map[string]string, urlSuffix URLSuffix) *Huggin
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/hunyuan.go b/internal/entity/models/hunyuan.go
index be0dbf0fd3..33d83c2da2 100644
--- a/internal/entity/models/hunyuan.go
+++ b/internal/entity/models/hunyuan.go
@@ -38,7 +38,7 @@ func NewHunyuanModel(baseURL map[string]string, urlSuffix URLSuffix) *HunyuanMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/hunyuan_test.go b/internal/entity/models/hunyuan_test.go
index cdf2ec6305..dc3ba54e86 100644
--- a/internal/entity/models/hunyuan_test.go
+++ b/internal/entity/models/hunyuan_test.go
@@ -96,6 +96,7 @@ func TestHunyuanFactory(t *testing.T) {
}
func TestHunyuanChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "hunyuan-pro" {
t.Errorf("model=%v", body["model"])
@@ -144,6 +145,7 @@ func TestHunyuanChatHappyPath(t *testing.T) {
}
func TestHunyuanChatNoReasoning(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"choices": []map[string]interface{}{{
@@ -173,6 +175,7 @@ func TestHunyuanChatNoReasoning(t *testing.T) {
}
func TestHunyuanChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newHunyuanForTest("http://unused").ChatWithMessages(
ctx,
@@ -186,6 +189,7 @@ func TestHunyuanChatRequiresAPIKey(t *testing.T) {
}
func TestHunyuanChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newHunyuanForTest("http://unused").ChatWithMessages(
@@ -198,6 +202,7 @@ func TestHunyuanChatRequiresMessages(t *testing.T) {
}
func TestHunyuanChatPropagatesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"bad key"}`))
@@ -218,6 +223,7 @@ func TestHunyuanChatPropagatesHTTPError(t *testing.T) {
}
func TestHunyuanStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanSSEServer(t, "/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}`+"\n"+
@@ -258,6 +264,7 @@ func TestHunyuanStreamHappyPath(t *testing.T) {
}
func TestHunyuanStreamSplitsReasoning(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanSSEServer(t, "/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
`data: {"choices":[{"index":0,"delta":{"reasoning_content":"step 1. "}}]}`+"\n"+
@@ -299,6 +306,7 @@ func TestHunyuanStreamSplitsReasoning(t *testing.T) {
}
func TestHunyuanStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
stream := false
ctx := t.Context()
@@ -316,6 +324,7 @@ func TestHunyuanStreamRejectsExplicitFalse(t *testing.T) {
}
func TestHunyuanStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newHunyuanForTest("http://unused").ChatStreamlyWithSender(
@@ -329,6 +338,7 @@ func TestHunyuanStreamRequiresSender(t *testing.T) {
}
func TestHunyuanStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanSSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"half"}}]}`+"\n",
)
@@ -348,6 +358,7 @@ func TestHunyuanStreamFailsWithoutTerminal(t *testing.T) {
}
func TestHunyuanStreamAcceptsTerminalWithoutDelta(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanSSEServer(t, "/chat/completions",
`data: {"choices":[{"finish_reason":"stop"}]}`+"\n\n",
)
@@ -374,6 +385,7 @@ func TestHunyuanStreamAcceptsTerminalWithoutDelta(t *testing.T) {
}
func TestHunyuanStreamRejectsMalformedFrame(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanSSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+
`data: {oops not json}`+"\n",
@@ -394,6 +406,7 @@ func TestHunyuanStreamRejectsMalformedFrame(t *testing.T) {
}
func TestHunyuanStreamSurfacesUpstreamError(t *testing.T) {
+ withSSRFBypass(t)
srv := newHunyuanSSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+
`data: {"error":{"message":"rate limit","type":"rate_limit_error"}}`+"\n",
@@ -417,6 +430,7 @@ func TestHunyuanStreamSurfacesUpstreamError(t *testing.T) {
}
func TestHunyuanListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -441,6 +455,7 @@ func TestHunyuanListModelsHappyPath(t *testing.T) {
}
func TestHunyuanListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newHunyuanForTest("http://unused").ListModels(ctx, &APIConfig{})
if err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -449,6 +464,7 @@ func TestHunyuanListModelsRequiresAPIKey(t *testing.T) {
}
func TestHunyuanCheckConnectionDelegatesToListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -464,6 +480,7 @@ func TestHunyuanCheckConnectionDelegatesToListModels(t *testing.T) {
}
func TestHunyuanCheckConnectionPropagatesError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -479,6 +496,7 @@ func TestHunyuanCheckConnectionPropagatesError(t *testing.T) {
}
func TestHunyuanBaseURLForRegionUnknown(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newHunyuanForTest("http://unused")
apiKey := "test-key"
@@ -490,6 +508,7 @@ func TestHunyuanBaseURLForRegionUnknown(t *testing.T) {
}
func TestHunyuanEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newHunyuanServer(t, http.MethodPost, "/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "hunyuan-embedding" {
@@ -522,6 +541,7 @@ func TestHunyuanEmbedHappyPath(t *testing.T) {
}
func TestHunyuanEmbedValidatesInputs(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
model := "hunyuan-embedding"
@@ -549,6 +569,7 @@ func TestHunyuanEmbedValidatesInputs(t *testing.T) {
}
func TestHunyuanAudioOCRReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newHunyuanForTest("http://unused")
model := "x"
diff --git a/internal/entity/models/jiekouai.go b/internal/entity/models/jiekouai.go
index 98631a5ca3..156f69c99e 100644
--- a/internal/entity/models/jiekouai.go
+++ b/internal/entity/models/jiekouai.go
@@ -36,7 +36,7 @@ func NewJieKouAIModel(baseURL map[string]string, urlSuffix URLSuffix) *JieKouAIM
// JieKouAI's methods issue requests without a per-call context deadline, so
// keep an explicit 120s client-level timeout to bound them. Built on the
// shared transport via NewDriverHTTPClient.
- client := NewDriverHTTPClient()
+ client := NewDriverHTTPClient(false)
client.Timeout = 120 * time.Second
return &JieKouAIModel{
baseModel: BaseModel{
diff --git a/internal/entity/models/jiekouai_test.go b/internal/entity/models/jiekouai_test.go
index 8037502827..dca3a2a16d 100644
--- a/internal/entity/models/jiekouai_test.go
+++ b/internal/entity/models/jiekouai_test.go
@@ -65,6 +65,7 @@ func newJieKouAIForTest(baseURL string) *JieKouAIModel {
}
func TestJieKouAIChatForcesNonStreaming(t *testing.T) {
+ withSSRFBypass(t)
srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodPost {
t.Errorf("method=%s, want POST", r.Method)
@@ -109,6 +110,7 @@ func TestJieKouAIChatForcesNonStreaming(t *testing.T) {
}
func TestJieKouAIChatSendsExplicitThinkingFalse(t *testing.T) {
+ withSSRFBypass(t)
srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["enable_thinking"] != false {
t.Errorf("enable_thinking=%v, want false", body["enable_thinking"])
@@ -138,6 +140,7 @@ func TestJieKouAIChatSendsExplicitThinkingFalse(t *testing.T) {
}
func TestJieKouAIStreamForcesStreaming(t *testing.T) {
+ withSSRFBypass(t)
srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/openai/v1/chat/completions" {
t.Errorf("path=%s, want /openai/v1/chat/completions", r.URL.Path)
@@ -190,6 +193,7 @@ func TestJieKouAIStreamForcesStreaming(t *testing.T) {
}
func TestJieKouAIListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -218,6 +222,7 @@ func TestJieKouAIListModelsHappyPath(t *testing.T) {
}
func TestJieKouAIListModelsRejectsMalformedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
for name, response := range map[string]interface{}{
@@ -238,6 +243,7 @@ func TestJieKouAIListModelsRejectsMalformedResponse(t *testing.T) {
}
func TestJieKouAIEmbedSendsValidatedRequest(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/openai/v1/embeddings" {
@@ -271,6 +277,7 @@ func TestJieKouAIEmbedSendsValidatedRequest(t *testing.T) {
}
func TestJieKouAIRerankHandlesNilConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJieKouAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/openai/v1/rerank" {
@@ -306,6 +313,7 @@ func TestJieKouAIRerankHandlesNilConfig(t *testing.T) {
}
func TestJieKouAIValidatesInputs(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
emptyKey := " "
diff --git a/internal/entity/models/jina.go b/internal/entity/models/jina.go
index fe2200f6ef..241e27e909 100644
--- a/internal/entity/models/jina.go
+++ b/internal/entity/models/jina.go
@@ -36,7 +36,7 @@ func NewJinaModel(baseURL map[string]string, urlSuffix URLSuffix) *JinaModel {
// Embed/Rerank/ListModels issue requests without a per-call context
// deadline, so keep an explicit 90s client-level timeout to bound them.
// Built on the shared transport via NewDriverHTTPClient.
- client := NewDriverHTTPClient()
+ client := NewDriverHTTPClient(false)
client.Timeout = 90 * time.Second
return &JinaModel{
baseModel: BaseModel{
diff --git a/internal/entity/models/jina_test.go b/internal/entity/models/jina_test.go
index 24084a1790..f2648c50d7 100644
--- a/internal/entity/models/jina_test.go
+++ b/internal/entity/models/jina_test.go
@@ -51,6 +51,7 @@ func newJinaForTest(baseURL string) *JinaModel {
}
func TestJinaChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "jina-vlm" {
@@ -91,6 +92,7 @@ func TestJinaChatHappyPath(t *testing.T) {
}
func TestJinaChatPreservesReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": "jina-chat",
@@ -129,6 +131,7 @@ func TestJinaChatSupportsToolCalls(t *testing.T) {
}
func TestJinaChatPropagatesConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["max_tokens"] != float64(128) {
@@ -167,6 +170,7 @@ func TestJinaChatPropagatesConfig(t *testing.T) {
}
func TestJinaChatValidation(t *testing.T) {
+ withSSRFBypass(t)
j := newJinaForTest("http://unused")
apiKey := "test-key"
emptyKey := ""
@@ -224,6 +228,7 @@ func TestJinaChatValidation(t *testing.T) {
}
func TestJinaChatStreamIsNotSupported(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
err := newJinaForTest("http://unused").ChatStreamlyWithSender(
t.Context(),
@@ -240,6 +245,7 @@ func TestJinaChatStreamIsNotSupported(t *testing.T) {
}
func TestJinaEmbedMeanPoolsMultivectorResponse(t *testing.T) {
+ withSSRFBypass(t)
srv := newJinaServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{{
@@ -269,6 +275,7 @@ func TestJinaEmbedMeanPoolsMultivectorResponse(t *testing.T) {
}
func TestJinaChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -285,6 +292,7 @@ func TestJinaChatRejectsHTTPError(t *testing.T) {
}
func TestJinaChatRejectsMalformedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"choices": []map[string]interface{}{}})
@@ -300,6 +308,7 @@ func TestJinaChatRejectsMalformedResponse(t *testing.T) {
}
func TestJinaChatRejectsUnknownRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
j := newJinaForTest("http://unused")
apiKey := "test-key"
@@ -313,6 +322,7 @@ func TestJinaChatRejectsUnknownRegion(t *testing.T) {
}
func TestJinaChatFallsBackToDefaultOnEmptyRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -333,6 +343,7 @@ func TestJinaChatFallsBackToDefaultOnEmptyRegion(t *testing.T) {
}
func TestJinaRerankDefaultsTopNToDocumentCount(t *testing.T) {
+ withSSRFBypass(t)
srv := newJinaServer(t, "/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["top_n"] != float64(2) {
t.Errorf("top_n=%v, want 2", body["top_n"])
diff --git a/internal/entity/models/lmstudio.go b/internal/entity/models/lmstudio.go
index 30858da5f4..29b0d5ffaa 100644
--- a/internal/entity/models/lmstudio.go
+++ b/internal/entity/models/lmstudio.go
@@ -39,7 +39,7 @@ func NewLmStudioModel(baseURL map[string]string, urlSuffix URLSuffix) *LmStudioM
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/localai.go b/internal/entity/models/localai.go
index 2445cfdd5f..a2e16e4800 100644
--- a/internal/entity/models/localai.go
+++ b/internal/entity/models/localai.go
@@ -44,7 +44,7 @@ func NewLocalAIModel(baseURL map[string]string, urlSuffix URLSuffix) *LocalAIMod
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/localai_test.go b/internal/entity/models/localai_test.go
index cd573d51d0..b4d03d6544 100644
--- a/internal/entity/models/localai_test.go
+++ b/internal/entity/models/localai_test.go
@@ -43,6 +43,7 @@ func TestLocalAIName(t *testing.T) {
}
func TestLocalAIStreamCancelsOnIdle(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// The server emits one valid chunk and then stalls. Without the
// watchdog, scanner.Scan() would hang forever. With the watchdog
@@ -107,6 +108,7 @@ func TestLocalAIStreamCancelsOnIdle(t *testing.T) {
}
func TestLocalAIStreamCompletesWithoutTriggeringWatchdog(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Sanity check: a fast, complete stream should not trip the
// watchdog even with a moderately tight idle window.
@@ -149,6 +151,7 @@ func TestLocalAIStreamCompletesWithoutTriggeringWatchdog(t *testing.T) {
}
func TestLocalAIStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
l := newLocalAIForTest("http://unused")
err := l.ChatStreamlyWithSender(ctx, "gpt-4",
@@ -160,6 +163,7 @@ func TestLocalAIStreamRequiresSender(t *testing.T) {
}
func TestLocalAIChatMissingBaseURLFailsClearly(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// LocalAI has no public default; resolveBaseURL must fail with a
// helpful message when neither the requested region nor "default"
@@ -175,6 +179,7 @@ func TestLocalAIChatMissingBaseURLFailsClearly(t *testing.T) {
}
func TestLocalAIChatOmitsAuthHeaderWhenKeyEmpty(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Optional-auth contract: LocalAI accepts an empty key, so the
// driver must NOT send a "Bearer " header in that case.
@@ -200,6 +205,7 @@ func TestLocalAIChatOmitsAuthHeaderWhenKeyEmpty(t *testing.T) {
}
func TestLocalAIChatSendsAuthHeaderWhenKeyProvided(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// And conversely: when a tenant has put LocalAI behind an auth
// proxy with a token, the driver does send the Bearer header.
@@ -223,6 +229,7 @@ func TestLocalAIChatSendsAuthHeaderWhenKeyProvided(t *testing.T) {
}
func TestLocalAIBalanceReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
l := newLocalAIForTest("http://unused")
_, err := l.Balance(ctx, &APIConfig{})
@@ -232,6 +239,7 @@ func TestLocalAIBalanceReturnsNoSuchMethod(t *testing.T) {
}
func TestLocalAIEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/embeddings" {
@@ -259,6 +267,7 @@ func TestLocalAIEmbedHappyPath(t *testing.T) {
}
func TestLocalAIEmbedRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// CodeRabbit caught that a response repeating data[*].index would
// silently overwrite the earlier vector. Verify the driver fails
@@ -279,6 +288,7 @@ func TestLocalAIEmbedRejectsDuplicateIndex(t *testing.T) {
}
func TestLocalAIEmbedRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"data":[{"embedding":[1],"index":7}]}`)
@@ -294,6 +304,7 @@ func TestLocalAIEmbedRejectsOutOfRangeIndex(t *testing.T) {
}
func TestLocalAIEmbedRejectsMissingSlot(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"data":[{"embedding":[1],"index":0}]}`)
@@ -309,6 +320,7 @@ func TestLocalAIEmbedRejectsMissingSlot(t *testing.T) {
}
func TestLocalAIEmbedEmptyInputShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("Embed([]) made an unexpected HTTP call")
@@ -372,6 +384,7 @@ func TestExtractLocalAIReasoning(t *testing.T) {
// when proxied through OpenAI-shim). The driver must surface it on
// ChatResponse.ReasonContent.
func TestLocalAIChatExtractsReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"choices":[{"message":{
@@ -401,6 +414,7 @@ func TestLocalAIChatExtractsReasoningContent(t *testing.T) {
// Non-streaming chat that uses message.thinking (Qwen3 via Ollama-shim
// inside LocalAI). The driver must surface it on ReasonContent too.
func TestLocalAIChatExtractsThinking(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"choices":[{"message":{
@@ -428,6 +442,7 @@ func TestLocalAIChatExtractsThinking(t *testing.T) {
// non-reasoning model) must produce empty ReasonContent without
// crashing or erroring.
func TestLocalAIChatHandlesAbsentReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"choices":[{"message":{
@@ -456,6 +471,7 @@ func TestLocalAIChatHandlesAbsentReasoning(t *testing.T) {
// chunks and delta.content chunks (kimi-k2.6, o-series shape).
// Reasoning must reach the sender's 2nd arg, content the 1st.
func TestLocalAIStreamExtractsReasoningContentDelta(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -502,6 +518,7 @@ func TestLocalAIStreamExtractsReasoningContentDelta(t *testing.T) {
// Streaming chat where the upstream uses delta.thinking (Qwen3 shape).
// The same handler must work.
func TestLocalAIStreamExtractsThinkingDelta(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -541,6 +558,7 @@ func TestLocalAIStreamExtractsThinkingDelta(t *testing.T) {
// Request-side: ChatConfig.Effort must flow into request body as
// reasoning_effort.
func TestLocalAIChatPropagatesReasoningEffort(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -576,6 +594,7 @@ func TestLocalAIChatPropagatesReasoningEffort(t *testing.T) {
// Request-side: ChatConfig.Thinking must flow into request body as
// enable_thinking (Qwen3-style).
func TestLocalAIChatPropagatesEnableThinking(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -607,6 +626,7 @@ func TestLocalAIChatPropagatesEnableThinking(t *testing.T) {
// Stream request also propagates the reasoning params.
func TestLocalAIStreamPropagatesReasoningParams(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/entity/models/longcat.go b/internal/entity/models/longcat.go
index 28cee88fc7..275ca78dc5 100644
--- a/internal/entity/models/longcat.go
+++ b/internal/entity/models/longcat.go
@@ -38,7 +38,7 @@ func NewLongCatModel(baseURL map[string]string, urlSuffix URLSuffix) *LongCatMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/longcat_test.go b/internal/entity/models/longcat_test.go
index 128dc17064..9814eee7aa 100644
--- a/internal/entity/models/longcat_test.go
+++ b/internal/entity/models/longcat_test.go
@@ -100,6 +100,7 @@ func TestLongCatNewModelWithCustomDefaultTransport(t *testing.T) {
}
func TestLongCatChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "LongCat-Flash-Chat" {
@@ -136,6 +137,7 @@ func TestLongCatChatHappyPath(t *testing.T) {
}
func TestLongCatChatExtractsReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// LongCat-Flash-Thinking returns the chain-of-thought in
// message.reasoning_content (OpenAI o-series shape). Live-probed
@@ -180,6 +182,7 @@ func TestLongCatChatExtractsReasoningContent(t *testing.T) {
// extracts the OpenAI-compatible usage block, including the nested
// completion_tokens_details.reasoning_tokens from LongCat's thinking mode.
func TestLongCatChatParsesUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -234,6 +237,7 @@ func TestLongCatChatParsesUsage(t *testing.T) {
// thinking model can emit all output as reasoning_content, leaving content
// null — that is a valid response, not an error.
func TestLongCatChatAcceptsReasoningOnlyResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -289,6 +293,7 @@ func TestLongCatChatAcceptsReasoningOnlyResponse(t *testing.T) {
// top_p — anything else is undocumented and must not be sent, since
// the maintainer specifically flagged this on PR #14809.
func TestLongCatChatDropsUndocumentedFields(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
for _, k := range []string{"stop", "reasoning_effort", "response_format", "tools", "tool_choice", "presence_penalty", "frequency_penalty", "n", "logprobs"} {
@@ -333,6 +338,7 @@ func TestLongCatChatDropsUndocumentedFields(t *testing.T) {
// requests aggregate usage via stream_options.include_usage and populates
// chatConfig.UsageResult from the final usage event.
func TestLongCatStreamRequestsIncludeUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
streamOpts, ok := body["stream_options"].(map[string]interface{})
@@ -373,6 +379,7 @@ func TestLongCatStreamRequestsIncludeUsage(t *testing.T) {
// event carrying completion_tokens_details.reasoning_tokens populates
// chatConfig.UsageResult.
func TestLongCatStreamParsesReasoningTokens(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
streamOpts, ok := body["stream_options"].(map[string]interface{})
@@ -410,6 +417,7 @@ func TestLongCatStreamParsesReasoningTokens(t *testing.T) {
}
func TestLongCatChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
_, err := m.ChatWithMessages(ctx, "LongCat-Flash-Chat",
@@ -422,6 +430,7 @@ func TestLongCatChatRequiresAPIKey(t *testing.T) {
}
func TestLongCatChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
apiKey := "test-key"
@@ -432,6 +441,7 @@ func TestLongCatChatRequiresMessages(t *testing.T) {
}
func TestLongCatChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -450,6 +460,7 @@ func TestLongCatChatRejectsHTTPError(t *testing.T) {
}
func TestLongCatStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -489,6 +500,7 @@ func TestLongCatStreamHappyPath(t *testing.T) {
}
func TestLongCatStreamExtractsReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Fixture matches the shape captured live from
// LongCat-Flash-Thinking against api.longcat.chat: deltas
@@ -532,6 +544,7 @@ func TestLongCatStreamExtractsReasoningContent(t *testing.T) {
}
func TestLongCatStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
apiKey := "test-key"
@@ -548,6 +561,7 @@ func TestLongCatStreamRejectsExplicitFalse(t *testing.T) {
}
func TestLongCatStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
apiKey := "test-key"
@@ -560,6 +574,7 @@ func TestLongCatStreamRequiresSender(t *testing.T) {
}
func TestLongCatStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"half"}}]}`+"\n",
@@ -581,6 +596,7 @@ func TestLongCatStreamFailsWithoutTerminal(t *testing.T) {
// which masked truncated or corrupted streams. The driver must now
// fail hard with a "longcat: invalid SSE event" wrapper.
func TestLongCatStreamRejectsMalformedFrame(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+
@@ -603,6 +619,7 @@ func TestLongCatStreamRejectsMalformedFrame(t *testing.T) {
// the "no choices" continue and leave the caller with a generic
// truncation error. The driver must surface the upstream error verbatim.
func TestLongCatStreamSurfacesUpstreamError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newLongCatSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+
@@ -625,6 +642,7 @@ func TestLongCatStreamSurfacesUpstreamError(t *testing.T) {
}
func TestLongCatListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var requests int
srv := newLongCatServer(t, "/openai/v1/models", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
@@ -677,6 +695,7 @@ func TestLongCatListModelsAndCheckConnection(t *testing.T) {
}
func TestLongCatListModelsRejectsInvalidResponses(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
for name, response := range map[string]string{
"missing data": `{}`,
@@ -699,6 +718,7 @@ func TestLongCatListModelsRejectsInvalidResponses(t *testing.T) {
}
func TestLongCatListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
for name, cfg := range map[string]*APIConfig{
"nil config": nil,
@@ -719,6 +739,7 @@ func TestLongCatListModelsRequiresAPIKey(t *testing.T) {
}
func TestLongCatEmbedReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
model := "x"
@@ -729,6 +750,7 @@ func TestLongCatEmbedReturnsNoSuchMethod(t *testing.T) {
}
func TestLongCatRerankReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
model := "x"
@@ -739,6 +761,7 @@ func TestLongCatRerankReturnsNoSuchMethod(t *testing.T) {
}
func TestLongCatBalanceReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
_, err := m.Balance(ctx, &APIConfig{})
@@ -748,6 +771,7 @@ func TestLongCatBalanceReturnsNoSuchMethod(t *testing.T) {
}
func TestLongCatAudioOCRReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newLongCatForTest("http://unused")
model := "x"
diff --git a/internal/entity/models/mineru.go b/internal/entity/models/mineru.go
index c41a3ef6bd..403a391bd4 100644
--- a/internal/entity/models/mineru.go
+++ b/internal/entity/models/mineru.go
@@ -35,7 +35,7 @@ func NewMinerUModel(baseURL map[string]string, urlSuffix URLSuffix) *MinerUModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/mineru_local.go b/internal/entity/models/mineru_local.go
index f478c7eeb8..18545c303a 100644
--- a/internal/entity/models/mineru_local.go
+++ b/internal/entity/models/mineru_local.go
@@ -37,7 +37,7 @@ func NewMinerLocalUModel(baseURL map[string]string, urlSuffix URLSuffix) *MinerU
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/minimax.go b/internal/entity/models/minimax.go
index 1f4b2c0ba3..4dde6a5035 100644
--- a/internal/entity/models/minimax.go
+++ b/internal/entity/models/minimax.go
@@ -39,7 +39,7 @@ func NewMinimaxModel(baseURL map[string]string, urlSuffix URLSuffix) *MinimaxMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/minimax_test.go b/internal/entity/models/minimax_test.go
index 5deff64f9e..0488927448 100644
--- a/internal/entity/models/minimax_test.go
+++ b/internal/entity/models/minimax_test.go
@@ -85,6 +85,7 @@ func TestMinimaxNewInstancePreservesConfig(t *testing.T) {
}
func TestMinimaxChatForcesNonStreaming(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodPost {
@@ -133,6 +134,7 @@ func TestMinimaxChatForcesNonStreaming(t *testing.T) {
}
func TestMinimaxChatRejectsEmptyChoices(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"choices": []map[string]interface{}{}})
@@ -154,6 +156,7 @@ func TestMinimaxChatRejectsEmptyChoices(t *testing.T) {
}
func TestMinimaxChatSurfacesBaseRespError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
// MiniMax rate-limit: HTTP 200 with base_resp, no choices.
@@ -184,6 +187,7 @@ func TestMinimaxChatSurfacesBaseRespError(t *testing.T) {
}
func TestMinimaxChatSurfacesOpenAIError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusTooManyRequests)
@@ -217,6 +221,7 @@ func TestMinimaxChatSurfacesOpenAIError(t *testing.T) {
}
func TestMinimaxStreamSurfacesBaseRespError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -244,6 +249,7 @@ func TestMinimaxStreamSurfacesBaseRespError(t *testing.T) {
}
func TestMinimaxStreamForcesStreaming(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodPost {
@@ -300,6 +306,7 @@ func TestMinimaxStreamForcesStreaming(t *testing.T) {
}
func TestMinimaxStreamAcceptsNilConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["stream"] != true {
@@ -326,6 +333,7 @@ func TestMinimaxStreamAcceptsNilConfig(t *testing.T) {
}
func TestMinimaxListModelsUsesBodylessGet(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -354,6 +362,7 @@ func TestMinimaxListModelsUsesBodylessGet(t *testing.T) {
}
func TestMinimaxListModelsRejectsMalformedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
for name, response := range map[string]interface{}{
@@ -374,6 +383,7 @@ func TestMinimaxListModelsRejectsMalformedResponse(t *testing.T) {
}
func TestMinimaxCheckConnectionUsesListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -395,6 +405,7 @@ func TestMinimaxCheckConnectionUsesListModels(t *testing.T) {
}
func TestMinimaxValidatesInputs(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
emptyKey := " "
diff --git a/internal/entity/models/mistral.go b/internal/entity/models/mistral.go
index 60a14d7241..40cf44eaee 100644
--- a/internal/entity/models/mistral.go
+++ b/internal/entity/models/mistral.go
@@ -45,7 +45,7 @@ func NewMistralModel(baseURL map[string]string, urlSuffix URLSuffix) *MistralMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/mistral_test.go b/internal/entity/models/mistral_test.go
index 6d93998270..d01785cac8 100644
--- a/internal/entity/models/mistral_test.go
+++ b/internal/entity/models/mistral_test.go
@@ -68,6 +68,7 @@ func TestMistralName(t *testing.T) {
}
func TestMistralChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "mistral-large-latest" {
@@ -106,6 +107,7 @@ func TestMistralChatHappyPath(t *testing.T) {
}
func TestMistralChatPropagatesConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["max_tokens"] != float64(64) {
@@ -144,6 +146,7 @@ func TestMistralChatPropagatesConfig(t *testing.T) {
}
func TestMistralChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
_, err := m.ChatWithMessages(ctx, "mistral-large-latest", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil)
@@ -158,6 +161,7 @@ func TestMistralChatRequiresAPIKey(t *testing.T) {
}
func TestMistralChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
apiKey := "test-key"
@@ -168,6 +172,7 @@ func TestMistralChatRequiresMessages(t *testing.T) {
}
func TestMistralChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -184,6 +189,7 @@ func TestMistralChatRejectsHTTPError(t *testing.T) {
}
func TestMistralChatFallsBackToDefaultOnEmptyRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Empty *Region pointer must fall back to the "default" entry, not
// be treated as an explicit "" region (which would miss the lookup).
@@ -207,6 +213,7 @@ func TestMistralChatFallsBackToDefaultOnEmptyRegion(t *testing.T) {
}
func TestMistralListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "x"}}})
@@ -222,6 +229,7 @@ func TestMistralListModelsFallsBackToDefaultOnEmptyRegion(t *testing.T) {
}
func TestMistralStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
apiKey := "test-key"
@@ -234,6 +242,7 @@ func TestMistralStreamRequiresSender(t *testing.T) {
}
func TestMistralChatRejectsUnknownRegion(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
apiKey := "test-key"
@@ -247,6 +256,7 @@ func TestMistralChatRejectsUnknownRegion(t *testing.T) {
}
func TestMistralStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
@@ -302,6 +312,7 @@ func TestMistralStreamHappyPath(t *testing.T) {
}
func TestMistralStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
apiKey := "test-key"
@@ -319,6 +330,7 @@ func TestMistralStreamRejectsExplicitFalse(t *testing.T) {
}
func TestMistralStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Body closes before [DONE] or a finish_reason -> driver must complain
// instead of pretending the stream finished cleanly.
@@ -341,6 +353,7 @@ func TestMistralStreamFailsWithoutTerminal(t *testing.T) {
}
func TestMistralListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -365,6 +378,7 @@ func TestMistralListModelsHappyPath(t *testing.T) {
}
func TestMistralListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
if _, err := m.ListModels(ctx, &APIConfig{}); err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -373,6 +387,7 @@ func TestMistralListModelsRequiresAPIKey(t *testing.T) {
}
func TestMistralCheckConnectionDelegatesToListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// 200 -> CheckConnection succeeds; 401 -> CheckConnection propagates.
okSrv := newMistralServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
@@ -396,6 +411,7 @@ func TestMistralCheckConnectionDelegatesToListModels(t *testing.T) {
}
func TestMistralBalanceReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
_, err := m.Balance(ctx, &APIConfig{})
@@ -405,6 +421,7 @@ func TestMistralBalanceReturnsNoSuchMethod(t *testing.T) {
}
func TestMistralRerankReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
q := "mistral-large-latest"
@@ -415,6 +432,7 @@ func TestMistralRerankReturnsNoSuchMethod(t *testing.T) {
}
func TestMistralTranscribeAudio(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
audioPath := filepath.Join(t.TempDir(), "sample.wav")
if err := os.WriteFile(audioPath, []byte("fake-audio"), 0o600); err != nil {
@@ -513,6 +531,7 @@ func TestMistralTranscribeAudio(t *testing.T) {
}
func TestMistralUnsupportedDefaultsReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
modelName := "mistral-large-latest"
@@ -549,6 +568,7 @@ func TestMistralUnsupportedDefaultsReturnNoSuchMethod(t *testing.T) {
}
func TestMistralEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "mistral-embed" {
@@ -584,6 +604,7 @@ func TestMistralEmbedHappyPath(t *testing.T) {
}
func TestMistralEmbedReordersByIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Upstream returns the three vectors in shuffled order. The driver
// must reorder them so the slot at position i corresponds to input i.
@@ -613,6 +634,7 @@ func TestMistralEmbedReordersByIndex(t *testing.T) {
}
func TestMistralEmbedEmptyInputShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Empty input must NOT make an HTTP call; the test fails the request
// rather than the assertion if it does.
@@ -635,6 +657,7 @@ func TestMistralEmbedEmptyInputShortCircuits(t *testing.T) {
}
func TestMistralEmbedRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
model := "mistral-embed"
@@ -645,6 +668,7 @@ func TestMistralEmbedRequiresAPIKey(t *testing.T) {
}
func TestMistralEmbedRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newMistralForTest("http://unused")
apiKey := "test-key"
@@ -660,6 +684,7 @@ func TestMistralEmbedRequiresModelName(t *testing.T) {
}
func TestMistralEmbedRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// A malformed upstream that repeats data[*].index would silently
// overwrite the earlier vector; the driver must fail loudly instead.
@@ -683,6 +708,7 @@ func TestMistralEmbedRejectsDuplicateIndex(t *testing.T) {
}
func TestMistralEmbedRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -703,6 +729,7 @@ func TestMistralEmbedRejectsOutOfRangeIndex(t *testing.T) {
}
func TestMistralEmbedRejectsMissingSlot(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Upstream returns only one of the two requested embeddings.
srv := newMistralServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
@@ -724,6 +751,7 @@ func TestMistralEmbedRejectsMissingSlot(t *testing.T) {
}
func TestMistralEmbedRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -745,6 +773,7 @@ func TestMistralEmbedRejectsHTTPError(t *testing.T) {
// Regression net: the existing string-content path stays green for every
// non-reasoning Mistral model.
func TestMistralChatHandlesStringContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -780,6 +809,7 @@ func TestMistralChatHandlesStringContent(t *testing.T) {
// from api.mistral.ai/v1/chat/completions against magistral-medium-latest
// with the prompt "When do two trains meet?".
func TestMistralChatExtractsReasoningFromStructuredContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -822,6 +852,7 @@ func TestMistralChatExtractsReasoningFromStructuredContent(t *testing.T) {
// magistral with a trivial answer that needed no reasoning returns the
// structured shape with only a `text` part. ReasonContent must be empty.
func TestMistralChatHandlesStructuredContentWithoutThinking(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -855,6 +886,7 @@ func TestMistralChatHandlesStructuredContentWithoutThinking(t *testing.T) {
// the parser forward-compatible with new Mistral content variants
// (audio chunks, citations, etc.) that ragflow doesn't surface yet.
func TestMistralChatIgnoresUnknownContentPartTypes(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMistralServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
diff --git a/internal/entity/models/model_test.go b/internal/entity/models/model_test.go
index f74ddb3039..90cfa9247b 100644
--- a/internal/entity/models/model_test.go
+++ b/internal/entity/models/model_test.go
@@ -235,6 +235,7 @@ func TestProviderConfigRejectsUnknownURLSuffixKey(t *testing.T) {
}
func TestPPIOProviderConfigLoadsIntoProviderManager(t *testing.T) {
+ withSSRFBypass(t)
dir, restore := setupProviderTestDir(t, "ppio.json")
defer restore()
diff --git a/internal/entity/models/model_test_helpers_test.go b/internal/entity/models/model_test_helpers_test.go
index 8e32defcf4..be97ba4d2e 100644
--- a/internal/entity/models/model_test_helpers_test.go
+++ b/internal/entity/models/model_test_helpers_test.go
@@ -3,8 +3,22 @@ package models
import (
"strings"
"testing"
+
+ "ragflow/internal/utility"
)
+// withSSRFBypass enables the test-only SSRF bypass for the duration of a test
+// and restores the previous value in t.Cleanup. Driver tests exercise request
+// and response logic against httptest servers bound to 127.0.0.1, which the
+// strict SSRF guard (NewDriverHTTPClient(false)) rejects. SSRF enforcement has
+// its own dedicated coverage in internal/utility/ssrf_test.go.
+func withSSRFBypass(t *testing.T) {
+ t.Helper()
+ prev := utility.AllowAnyHostForTest
+ utility.AllowAnyHostForTest = true
+ t.Cleanup(func() { utility.AllowAnyHostForTest = prev })
+}
+
func requireNoSuchMethod(t *testing.T, name string, err error) {
t.Helper()
if err == nil {
diff --git a/internal/entity/models/modelscope.go b/internal/entity/models/modelscope.go
index d1da4bc676..c07b743c64 100644
--- a/internal/entity/models/modelscope.go
+++ b/internal/entity/models/modelscope.go
@@ -61,7 +61,7 @@ func NewModelScopeModel(baseURL map[string]string, urlSuffix URLSuffix) *ModelSc
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/modelscope_test.go b/internal/entity/models/modelscope_test.go
index 704909b6f1..bec6da6a10 100644
--- a/internal/entity/models/modelscope_test.go
+++ b/internal/entity/models/modelscope_test.go
@@ -94,6 +94,7 @@ func TestModelScopeNewModelWithCustomDefaultTransport(t *testing.T) {
}
func TestModelScopeChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -146,6 +147,7 @@ func TestModelScopeChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T)
}
func TestModelScopeChatSendsAuthHeaderWhenKeyProvided(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer ms-test" {
@@ -166,6 +168,7 @@ func TestModelScopeChatSendsAuthHeaderWhenKeyProvided(t *testing.T) {
}
func TestModelScopeChatExtractsReasoningFields(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"choices":[{"message":{
@@ -188,6 +191,7 @@ func TestModelScopeChatExtractsReasoningFields(t *testing.T) {
}
func TestModelScopeStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
@@ -252,6 +256,7 @@ func TestModelScopeStreamHappyPath(t *testing.T) {
}
func TestModelScopeStreamRejectsFalseStreamConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newModelScopeForTest("http://unused")
stream := false
@@ -267,6 +272,7 @@ func TestModelScopeStreamRejectsFalseStreamConfig(t *testing.T) {
}
func TestModelScopeStreamCancelsOnIdle(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
withModelScopeIdleTimeout(t, 200*time.Millisecond)
@@ -297,6 +303,7 @@ func TestModelScopeStreamCancelsOnIdle(t *testing.T) {
}
func TestModelScopeListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
@@ -325,6 +332,7 @@ func TestModelScopeListModelsAndCheckConnection(t *testing.T) {
}
func TestModelScopeMissingBaseURLFailsClearly(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := NewModelScopeModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"})
_, err := m.ChatWithMessages(ctx, "Qwen/Qwen2.5-7B-Instruct",
@@ -336,6 +344,7 @@ func TestModelScopeMissingBaseURLFailsClearly(t *testing.T) {
}
func TestModelScopeUnsupportedMethodsReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newModelScopeForTest("http://unused")
model := "Qwen/Qwen2.5-7B-Instruct"
diff --git a/internal/entity/models/moonshot.go b/internal/entity/models/moonshot.go
index 4e34d4f8c9..b38586ff2d 100644
--- a/internal/entity/models/moonshot.go
+++ b/internal/entity/models/moonshot.go
@@ -38,7 +38,7 @@ func NewMoonshotModel(baseURL map[string]string, urlSuffix URLSuffix) *MoonshotM
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/moonshot_test.go b/internal/entity/models/moonshot_test.go
index 483bc6c970..244fb28ef0 100644
--- a/internal/entity/models/moonshot_test.go
+++ b/internal/entity/models/moonshot_test.go
@@ -85,6 +85,7 @@ func TestMoonshotNewInstancePreservesConfig(t *testing.T) {
}
func TestMoonshotChatForcesNonStreaming(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodPost {
@@ -132,6 +133,7 @@ func TestMoonshotChatForcesNonStreaming(t *testing.T) {
}
func TestMoonshotChatSupportsTools(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMoonshotServer(t, func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
tools, ok := body["tools"].([]interface{})
@@ -185,6 +187,7 @@ func TestMoonshotChatSupportsTools(t *testing.T) {
}
func TestMoonshotChatParsesCompletionSchema(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMoonshotServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -250,6 +253,7 @@ func TestMoonshotChatParsesCompletionSchema(t *testing.T) {
}
func TestMoonshotStreamForcesStreaming(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodPost {
@@ -314,6 +318,7 @@ func TestMoonshotStreamForcesStreaming(t *testing.T) {
}
func TestMoonshotStreamDoesNotSendDoneAfterScannerError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMoonshotServer(t, func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["stream"] != true {
@@ -349,6 +354,7 @@ func TestMoonshotStreamDoesNotSendDoneAfterScannerError(t *testing.T) {
}
func TestMoonshotListModelsUsesBodylessGet(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -377,6 +383,7 @@ func TestMoonshotListModelsUsesBodylessGet(t *testing.T) {
}
func TestMoonshotBalanceUsesBodylessGet(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newMoonshotServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -411,6 +418,7 @@ func TestMoonshotBalanceUsesBodylessGet(t *testing.T) {
}
func TestMoonshotRejectsMalformedResponses(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
tests := []struct {
@@ -463,6 +471,7 @@ func TestMoonshotRejectsMalformedResponses(t *testing.T) {
}
func TestMoonshotValidatesInputs(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
emptyKey := " "
diff --git a/internal/entity/models/n1n.go b/internal/entity/models/n1n.go
index 6ae530ae76..6207ce5716 100644
--- a/internal/entity/models/n1n.go
+++ b/internal/entity/models/n1n.go
@@ -38,7 +38,7 @@ func NewN1NModel(baseURL map[string]string, urlSuffix URLSuffix) *N1NModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/non_chat_usage_test.go b/internal/entity/models/non_chat_usage_test.go
index e260c20012..aa46038238 100644
--- a/internal/entity/models/non_chat_usage_test.go
+++ b/internal/entity/models/non_chat_usage_test.go
@@ -8,6 +8,7 @@ import (
)
func TestProviderEmbeddingAndRerankUsage(t *testing.T) {
+ withSSRFBypass(t)
type providerCase struct {
name string
newModel func(string) ModelDriver
diff --git a/internal/entity/models/novita.go b/internal/entity/models/novita.go
index fb75cc6274..839b6206d2 100644
--- a/internal/entity/models/novita.go
+++ b/internal/entity/models/novita.go
@@ -39,7 +39,7 @@ func NewNovitaModel(baseURL map[string]string, urlSuffix URLSuffix) *NovitaModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/novita_test.go b/internal/entity/models/novita_test.go
index b1a2eda284..9bcf17c736 100644
--- a/internal/entity/models/novita_test.go
+++ b/internal/entity/models/novita_test.go
@@ -255,6 +255,7 @@ func TestNovitaName(t *testing.T) {
}
func TestNovitaChatPureText(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -280,6 +281,7 @@ func TestNovitaChatPureText(t *testing.T) {
}
func TestNovitaChatExtractsThinkTags(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// qwen3-style response: ... embedded in content.
// Driver must split it into Answer + ReasonContent.
@@ -318,6 +320,7 @@ func TestNovitaChatExtractsThinkTags(t *testing.T) {
// to ChatResponse.ReasonContent. Live-confirmed against
// api.novita.ai/openai/v1/chat/completions with deepseek/deepseek-v3.1.
func TestNovitaChatExtractsReasoningContentField(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -356,6 +359,7 @@ func TestNovitaChatExtractsReasoningContentField(t *testing.T) {
// (not delta.content with tags). The driver must forward
// those chunks via the sender's second arg.
func TestNovitaStreamExtractsDeltaReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"step 1. "}}]}`+"\n"+
@@ -401,6 +405,7 @@ func TestNovitaStreamExtractsDeltaReasoningContent(t *testing.T) {
// so a tenant can switch a deepseek-v3.1 / glm-4.5 / qwen3 deployment
// out of its default thinking mode without prompt-level hacks.
func TestNovitaChatPropagatesEnableThinking(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
cases := []struct {
name string
@@ -446,6 +451,7 @@ func TestNovitaChatPropagatesEnableThinking(t *testing.T) {
// silently flip behavior for downstream proxies that distinguish
// "field absent" from "field present with default". Leave it out.
func TestNovitaChatOmitsEnableThinkingWhenUnset(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if _, present := body["enable_thinking"]; present {
@@ -473,6 +479,7 @@ func TestNovitaChatOmitsEnableThinkingWhenUnset(t *testing.T) {
// case for ChatStreamlyWithSender so callers get the same toggle
// regardless of streaming mode.
func TestNovitaStreamPropagatesEnableThinking(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -505,6 +512,7 @@ func TestNovitaStreamPropagatesEnableThinking(t *testing.T) {
}
func TestNovitaChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newNovitaForTest("http://unused").ChatWithMessages(ctx, "m",
[]Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil)
@@ -514,6 +522,7 @@ func TestNovitaChatRequiresAPIKey(t *testing.T) {
}
func TestNovitaChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newNovitaForTest("http://unused").ChatWithMessages(ctx, "m", nil,
@@ -524,6 +533,7 @@ func TestNovitaChatRequiresMessages(t *testing.T) {
}
func TestNovitaChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -544,6 +554,7 @@ func TestNovitaChatRejectsHTTPError(t *testing.T) {
// delta.content must surface reasoning chunks through the sender's
// second arg, and visible content through the first.
func TestNovitaStreamSplitsThinkTags(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Simulate the realistic case where tags span deltas — split
// "" across two chunks, and split "" too.
@@ -595,6 +606,7 @@ func TestNovitaStreamSplitsThinkTags(t *testing.T) {
// Streaming for a non-reasoning model that emits only content chunks
// must continue to work unchanged.
func TestNovitaStreamPureContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaSSEServer(t, "/openai/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -633,6 +645,7 @@ func TestNovitaStreamPureContent(t *testing.T) {
}
func TestNovitaStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newNovitaForTest("http://unused").ChatStreamlyWithSender(ctx, "m",
@@ -644,6 +657,7 @@ func TestNovitaStreamRequiresSender(t *testing.T) {
}
func TestNovitaStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
stream := false
@@ -659,6 +673,7 @@ func TestNovitaStreamRejectsExplicitFalse(t *testing.T) {
}
func TestNovitaListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -682,6 +697,7 @@ func TestNovitaListModelsHappyPath(t *testing.T) {
}
func TestNovitaCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []map[string]interface{}{{"id": "x"}}})
@@ -695,6 +711,7 @@ func TestNovitaCheckConnection(t *testing.T) {
}
func TestNovitaRerankHappyPathReordersByIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "baai/bge-reranker-v2-m3" {
@@ -732,6 +749,7 @@ func TestNovitaRerankHappyPathReordersByIndex(t *testing.T) {
}
func TestNovitaRerankRespectsTopNConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["top_n"] != float64(2) {
@@ -749,6 +767,7 @@ func TestNovitaRerankRespectsTopNConfig(t *testing.T) {
}
func TestNovitaRerankEmptyDocumentsShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
model := "x"
@@ -762,6 +781,7 @@ func TestNovitaRerankEmptyDocumentsShortCircuits(t *testing.T) {
}
func TestNovitaRerankRequiresApiKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := "x"
_, err := newNovitaForTest("http://unused").Rerank(ctx, &model, "q", []string{"a"}, &APIConfig{}, nil, nil)
@@ -771,6 +791,7 @@ func TestNovitaRerankRequiresApiKey(t *testing.T) {
}
func TestNovitaRerankRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newNovitaForTest("http://unused").Rerank(ctx, nil, "q", []string{"a"}, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -780,6 +801,7 @@ func TestNovitaRerankRequiresModelName(t *testing.T) {
}
func TestNovitaRerankRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_, _ = io.WriteString(w, `{"results":[{"index":5,"relevance_score":0.5}]}`)
@@ -795,6 +817,7 @@ func TestNovitaRerankRejectsOutOfRangeIndex(t *testing.T) {
}
func TestNovitaRerankRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNovitaServer(t, "/openai/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_, _ = io.WriteString(w, `{"results":[{"index":0,"relevance_score":0.9},{"index":0,"relevance_score":0.5}]}`)
@@ -810,6 +833,7 @@ func TestNovitaRerankRejectsDuplicateIndex(t *testing.T) {
}
func TestNovitaRerankSurfacesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
@@ -826,6 +850,7 @@ func TestNovitaRerankSurfacesHTTPError(t *testing.T) {
}
func TestNovitaRerankRejectsMissingRerankSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
model := "x"
@@ -840,6 +865,7 @@ func TestNovitaRerankRejectsMissingRerankSuffix(t *testing.T) {
}
func TestNovitaBalanceReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Balance IS implemented (makes HTTP call), not a "no such method" stub.
// With dummy key it should reach the HTTP call stage, not fail at APIConfigCheck.
@@ -851,6 +877,7 @@ func TestNovitaBalanceReturnsNoSuchMethod(t *testing.T) {
}
func TestNovitaAudioOCRReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := "x"
apiKey := "test-key"
@@ -873,6 +900,7 @@ func TestNovitaAudioOCRReturnNoSuchMethod(t *testing.T) {
// slash. baseURLForRegion now trims the trailing "/" so all three
// endpoint builders (Chat, Stream, ListModels) emit clean paths.
func TestNovitaBaseURLTrimsTrailingSlash(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
cases := []struct {
name string
diff --git a/internal/entity/models/nvidia.go b/internal/entity/models/nvidia.go
index 333b89da75..ab10b38de4 100644
--- a/internal/entity/models/nvidia.go
+++ b/internal/entity/models/nvidia.go
@@ -49,7 +49,7 @@ func NewNvidiaModel(baseURL map[string]string, urlSuffix URLSuffix) *NvidiaModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
catalogURL: nvidiaCatalogURL,
hostedAPIHost: nvidiaHostedAPIHost,
diff --git a/internal/entity/models/nvidia_rerank_test.go b/internal/entity/models/nvidia_rerank_test.go
index ad3f95ddfb..9e7a35397c 100644
--- a/internal/entity/models/nvidia_rerank_test.go
+++ b/internal/entity/models/nvidia_rerank_test.go
@@ -52,6 +52,7 @@ func newNvidiaModelForTest(baseURL string) *NvidiaModel {
}
func TestNvidiaRerankHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "nvidia/nv-rerankqa-mistral-4b-v3" {
@@ -110,6 +111,7 @@ func TestNvidiaRerankHappyPath(t *testing.T) {
}
func TestNvidiaRerankTopNClamp(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["top_n"] != float64(2) {
@@ -135,6 +137,7 @@ func TestNvidiaRerankTopNClamp(t *testing.T) {
}
func TestNvidiaRerankEmptyDocuments(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := newNvidiaModelForTest("http://unused")
apiKey := "test-key"
@@ -149,6 +152,7 @@ func TestNvidiaRerankEmptyDocuments(t *testing.T) {
}
func TestNvidiaRerankRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := newNvidiaModelForTest("http://unused")
modelName := "nvidia/nv-rerankqa-mistral-4b-v3"
@@ -159,6 +163,7 @@ func TestNvidiaRerankRequiresAPIKey(t *testing.T) {
}
func TestNvidiaRerankRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := newNvidiaModelForTest("http://unused")
apiKey := "test-key"
@@ -169,6 +174,7 @@ func TestNvidiaRerankRequiresModelName(t *testing.T) {
}
func TestNvidiaRerankRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -186,6 +192,7 @@ func TestNvidiaRerankRejectsHTTPError(t *testing.T) {
}
func TestNvidiaRerankRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newNvidiaRerankServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
diff --git a/internal/entity/models/nvidia_test.go b/internal/entity/models/nvidia_test.go
index 8896b88b42..961c5f1a80 100644
--- a/internal/entity/models/nvidia_test.go
+++ b/internal/entity/models/nvidia_test.go
@@ -12,6 +12,7 @@ import (
)
func TestNvidiaListModelsUsesExactEndpointIDs(t *testing.T) {
+ withSSRFBypass(t)
const apiKey = "nvapi-test"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -99,6 +100,7 @@ func TestParseNvidiaModelListInfersTypesForPresetWithoutTypes(t *testing.T) {
}
func TestNvidiaListModelsFiltersHostedCatalog(t *testing.T) {
+ withSSRFBypass(t)
const apiKey = "nvapi-test"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -142,6 +144,7 @@ func TestNvidiaListModelsFiltersHostedCatalog(t *testing.T) {
}
func TestNvidiaFetchHostedCatalogPaginates(t *testing.T) {
+ withSSRFBypass(t)
resources := make([]nvidiaCatalogResource, nvidiaCatalogPageSize+1)
for i := range resources {
resources[i] = nvidiaCatalogResource{DisplayName: fmt.Sprintf("model-%d", i)}
@@ -192,6 +195,7 @@ func TestNvidiaFetchHostedCatalogPaginates(t *testing.T) {
}
func TestNvidiaListModelsRejectsPartialHostedCatalog(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
_ = json.NewEncoder(w).Encode(ModelList{Models: []ModelListItem{{ID: "meta/llama-3.1-8b-instruct"}}})
diff --git a/internal/entity/models/ollama.go b/internal/entity/models/ollama.go
index fd188a31bd..f72d440d9a 100644
--- a/internal/entity/models/ollama.go
+++ b/internal/entity/models/ollama.go
@@ -40,7 +40,7 @@ func NewOllamaModel(baseURL map[string]string, urlSuffix URLSuffix) *OllamaModel
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/ollama_test.go b/internal/entity/models/ollama_test.go
index daf65d9e69..786ad13f40 100644
--- a/internal/entity/models/ollama_test.go
+++ b/internal/entity/models/ollama_test.go
@@ -28,6 +28,7 @@ func newOllamaForListModelsTest(baseURL string) *OllamaModel {
}
func TestOllamaListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -54,6 +55,7 @@ func TestOllamaListModels(t *testing.T) {
}
func TestOllamaListModelsFallsBackToModelField(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Some entries may carry only the "model" field; it should be used as the name.
@@ -74,6 +76,7 @@ func TestOllamaListModelsFallsBackToModelField(t *testing.T) {
}
func TestOllamaListModelsRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@@ -87,6 +90,7 @@ func TestOllamaListModelsRejectsHTTPError(t *testing.T) {
}
func TestOllamaListModelsRequiresBaseURL(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := NewOllamaModel(map[string]string{}, URLSuffix{Models: "api/tags"})
if _, err := m.ListModels(ctx, &APIConfig{}); err == nil {
diff --git a/internal/entity/models/openai.go b/internal/entity/models/openai.go
index b8166d7341..b6cb75d066 100644
--- a/internal/entity/models/openai.go
+++ b/internal/entity/models/openai.go
@@ -44,7 +44,7 @@ func NewOpenAIModel(baseURL map[string]string, urlSuffix URLSuffix) *OpenAIModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/openai_api_compatible.go b/internal/entity/models/openai_api_compatible.go
index 78b60c30f4..5a0ee13c9a 100644
--- a/internal/entity/models/openai_api_compatible.go
+++ b/internal/entity/models/openai_api_compatible.go
@@ -326,7 +326,7 @@ func (m *OpenAIAPICompatibleModel) TranscribeAudio(ctx context.Context, modelNam
var result struct {
Text string `json:"text"`
}
- if err := json.Unmarshal(respBody, &result); err != nil {
+ if err = json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w, body=%s", err, string(respBody))
}
diff --git a/internal/entity/models/openai_compatible_usage_test.go b/internal/entity/models/openai_compatible_usage_test.go
index 79b7476514..11b8d8980b 100644
--- a/internal/entity/models/openai_compatible_usage_test.go
+++ b/internal/entity/models/openai_compatible_usage_test.go
@@ -25,6 +25,7 @@ import (
)
func TestOpenAICompatibleProvidersExtractStreamingUsage(t *testing.T) {
+ withSSRFBypass(t)
providers := []struct {
name string
new func(string) ModelDriver
diff --git a/internal/entity/models/openai_test.go b/internal/entity/models/openai_test.go
index a717c29810..bb71848e35 100644
--- a/internal/entity/models/openai_test.go
+++ b/internal/entity/models/openai_test.go
@@ -68,6 +68,7 @@ func TestOpenAIConfigAdvertisedAudioModelsHaveSuffixes(t *testing.T) {
}
func TestOpenAITranscribeAudioPostsMultipartToAudioEndpoint(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -146,6 +147,7 @@ func TestOpenAITranscribeAudioPostsMultipartToAudioEndpoint(t *testing.T) {
}
func TestOpenAITranscribeAudioWithSenderStreamsDeltas(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -214,6 +216,7 @@ func TestOpenAITranscribeAudioWithSenderStreamsDeltas(t *testing.T) {
}
func TestOpenAIAudioSpeechPostsJSONToAudioEndpoint(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -281,6 +284,7 @@ func TestOpenAIAudioSpeechPostsJSONToAudioEndpoint(t *testing.T) {
}
func TestOpenAIAudioSpeechRequiresVoice(t *testing.T) {
+ withSSRFBypass(t)
apiKey := "test-key"
model := "tts-1"
input := "hello"
@@ -300,6 +304,7 @@ func TestOpenAIAudioSpeechRequiresVoice(t *testing.T) {
}
func TestOpenAIAudioSpeechRejectsNonStringVoice(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
model := "tts-1"
@@ -319,6 +324,7 @@ func TestOpenAIAudioSpeechRejectsNonStringVoice(t *testing.T) {
}
func TestOpenAIAudioSpeechWithSenderStreamsRawAudio(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method=%s, want POST", r.Method)
@@ -383,6 +389,7 @@ func TestOpenAIAudioSpeechWithSenderStreamsRawAudio(t *testing.T) {
}
func TestOpenAIAudioSpeechWithSenderStreamsSSEAudioDeltas(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Accept"); got != "text/event-stream" {
diff --git a/internal/entity/models/openrouter.go b/internal/entity/models/openrouter.go
index da26203c64..e573467dfa 100644
--- a/internal/entity/models/openrouter.go
+++ b/internal/entity/models/openrouter.go
@@ -41,7 +41,7 @@ func NewOpenRouterModel(baseURL map[string]string, urlSuffix URLSuffix) *OpenRou
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/openrouter_test.go b/internal/entity/models/openrouter_test.go
index e3c51f695a..7abc7a2452 100644
--- a/internal/entity/models/openrouter_test.go
+++ b/internal/entity/models/openrouter_test.go
@@ -77,6 +77,7 @@ func TestOpenRouterAudioFormatUsesConfiguredValue(t *testing.T) {
}
func TestOpenRouterTranscribeAudioHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
audio := []byte("RIFF test audio")
srv := newOpenRouterServer(t, "/audio/transcriptions", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
@@ -143,6 +144,7 @@ func TestOpenRouterTranscribeAudioHappyPath(t *testing.T) {
}
func TestOpenRouterTranscribeAudioInfersFormat(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newOpenRouterServer(t, "/audio/transcriptions", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
inputAudio, ok := body["input_audio"].(map[string]interface{})
@@ -167,6 +169,7 @@ func TestOpenRouterTranscribeAudioInfersFormat(t *testing.T) {
}
func TestOpenRouterTranscribeAudioValidatesInputs(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
modelName := "openai/whisper-large-v3"
apiKey := "test-key"
@@ -195,6 +198,7 @@ func TestOpenRouterTranscribeAudioValidatesInputs(t *testing.T) {
}
func TestOpenRouterTranscribeAudioValidatesASRSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "openai/whisper-large-v3"
@@ -208,6 +212,7 @@ func TestOpenRouterTranscribeAudioValidatesASRSuffix(t *testing.T) {
}
func TestOpenRouterTranscribeAudioHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newOpenRouterServer(t, "/audio/transcriptions", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
@@ -231,6 +236,7 @@ func TestOpenRouterTranscribeAudioHTTPError(t *testing.T) {
// and reasoning is requested via OpenRouter's standard `reasoning` object rather
// than the non-standard `thinking` key that the API silently ignores.
func TestOpenRouterChatStreamlyRequest(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var gotPath string
var gotBody map[string]interface{}
diff --git a/internal/entity/models/orcarouter.go b/internal/entity/models/orcarouter.go
index 3270d4622d..aaf887236a 100644
--- a/internal/entity/models/orcarouter.go
+++ b/internal/entity/models/orcarouter.go
@@ -35,7 +35,7 @@ func NewOrcaRouterModel(baseURL map[string]string, urlSuffix URLSuffix) *OrcaRou
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/paddleocr.go b/internal/entity/models/paddleocr.go
index 251f0d15d8..69a0562a2f 100644
--- a/internal/entity/models/paddleocr.go
+++ b/internal/entity/models/paddleocr.go
@@ -40,7 +40,7 @@ func NewPaddleOCRModel(baseURL map[string]string, urlSuffix URLSuffix) *PaddleOC
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/paddleocr_local.go b/internal/entity/models/paddleocr_local.go
index d14cf9bf46..e195db04fb 100644
--- a/internal/entity/models/paddleocr_local.go
+++ b/internal/entity/models/paddleocr_local.go
@@ -37,7 +37,7 @@ func NewPaddleOCRLocalModel(baseURL map[string]string, urlSuffix URLSuffix) *Pad
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/perplexity.go b/internal/entity/models/perplexity.go
index 9292b7c58f..428e6cea8e 100644
--- a/internal/entity/models/perplexity.go
+++ b/internal/entity/models/perplexity.go
@@ -36,7 +36,7 @@ func NewPerplexityModel(baseURL map[string]string, urlSuffix URLSuffix) *Perplex
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/perplexity_test.go b/internal/entity/models/perplexity_test.go
index 77cfca0804..b596280991 100644
--- a/internal/entity/models/perplexity_test.go
+++ b/internal/entity/models/perplexity_test.go
@@ -60,6 +60,7 @@ func TestPerplexityFactory(t *testing.T) {
}
func TestPerplexityChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/v1/sonar" {
@@ -111,6 +112,7 @@ func TestPerplexityChatHappyPath(t *testing.T) {
}
func TestPerplexityChatSkipsReasoningEffortForNonReasoningModel(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "sonar" {
@@ -148,6 +150,7 @@ func TestPerplexityChatSkipsReasoningEffortForNonReasoningModel(t *testing.T) {
}
func TestPerplexityChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newPerplexityForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -157,6 +160,7 @@ func TestPerplexityChatRequiresModelName(t *testing.T) {
}
func TestPerplexityChatRequiresApiKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newPerplexityForTest("http://unused").ChatWithMessages(ctx, "sonar", []Message{{Role: "user", Content: "x"}}, nil, nil, nil)
if err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -165,6 +169,7 @@ func TestPerplexityChatRequiresApiKey(t *testing.T) {
}
func TestPerplexityStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/v1/sonar" {
@@ -215,6 +220,7 @@ func TestPerplexityStreamHappyPath(t *testing.T) {
}
func TestPerplexityStreamStopsOnDoneMarker(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -248,6 +254,7 @@ func TestPerplexityStreamStopsOnDoneMarker(t *testing.T) {
}
func TestPerplexityListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -281,6 +288,7 @@ func TestPerplexityListModelsAndCheckConnection(t *testing.T) {
}
func TestPerplexityListModelsAcceptsBareArray(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode([]map[string]interface{}{
@@ -301,6 +309,7 @@ func TestPerplexityListModelsAcceptsBareArray(t *testing.T) {
}
func TestPerplexityEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPerplexityServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/v1/embeddings" {
@@ -350,6 +359,7 @@ func TestPerplexityEmbedHappyPath(t *testing.T) {
}
func TestPerplexityEmbedEmptyTextsReturnsEmpty(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
modelName := "pplx-embed-v1-0.6b"
apiKey := "test-key"
@@ -363,6 +373,7 @@ func TestPerplexityEmbedEmptyTextsReturnsEmpty(t *testing.T) {
}
func TestPerplexityEmbedRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newPerplexityForTest("http://unused").Embed(ctx, nil, []string{"x"}, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -372,6 +383,7 @@ func TestPerplexityEmbedRequiresModelName(t *testing.T) {
}
func TestPerplexityUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newPerplexityForTest("http://unused")
if _, err := m.Rerank(ctx, nil, "", nil, nil, nil, nil); err == nil || !strings.Contains(err.Error(), "no such method") {
diff --git a/internal/entity/models/ppio.go b/internal/entity/models/ppio.go
index cc2034c454..8867f8c41a 100644
--- a/internal/entity/models/ppio.go
+++ b/internal/entity/models/ppio.go
@@ -39,7 +39,7 @@ func NewPPIOModel(baseURL map[string]string, urlSuffix URLSuffix) *PPIOModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/ppio_test.go b/internal/entity/models/ppio_test.go
index 4b248abacd..6293e55e05 100644
--- a/internal/entity/models/ppio_test.go
+++ b/internal/entity/models/ppio_test.go
@@ -76,6 +76,7 @@ func TestPPIONewModelWithCustomDefaultTransport(t *testing.T) {
}
func TestPPIOChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
@@ -162,6 +163,7 @@ func TestPPIOChatHappyPath(t *testing.T) {
}
func TestPPIOChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newPPIOForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -171,6 +173,7 @@ func TestPPIOChatRequiresModelName(t *testing.T) {
}
func TestPPIOChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newPPIOForTest("http://unused").ChatWithMessages(ctx, "deepseek/deepseek-r1", nil, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -180,6 +183,7 @@ func TestPPIOChatRequiresMessages(t *testing.T) {
}
func TestPPIOChatSurfacesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
http.Error(w, "bad key", http.StatusUnauthorized)
@@ -194,6 +198,7 @@ func TestPPIOChatSurfacesHTTPError(t *testing.T) {
}
func TestPPIOChatRejectsProviderError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -210,6 +215,7 @@ func TestPPIOChatRejectsProviderError(t *testing.T) {
}
func TestPPIOStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
@@ -276,6 +282,7 @@ func TestPPIOStreamHappyPath(t *testing.T) {
}
func TestPPIOEmbedRecordsUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/embeddings" {
@@ -315,6 +322,7 @@ func TestPPIOEmbedRecordsUsage(t *testing.T) {
}
func TestPPIORerankRecordsUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/rerank" {
@@ -355,6 +363,7 @@ func TestPPIORerankRecordsUsage(t *testing.T) {
}
func TestPPIOStreamSurfacesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
http.Error(w, "bad key", http.StatusUnauthorized)
@@ -375,6 +384,7 @@ func TestPPIOStreamSurfacesHTTPError(t *testing.T) {
}
func TestPPIOStreamStopsOnSenderError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -396,6 +406,7 @@ func TestPPIOStreamStopsOnSenderError(t *testing.T) {
}
func TestPPIOStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
stream := false
@@ -414,6 +425,7 @@ func TestPPIOStreamRejectsExplicitFalse(t *testing.T) {
}
func TestPPIOStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newPPIOForTest("http://unused").ChatStreamlyWithSender(
@@ -428,6 +440,7 @@ func TestPPIOStreamRequiresSender(t *testing.T) {
}
func TestPPIOStreamRequiresTerminalEvent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -449,6 +462,7 @@ func TestPPIOStreamRequiresTerminalEvent(t *testing.T) {
}
func TestPPIOListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -481,6 +495,7 @@ func TestPPIOListModelsAndCheckConnection(t *testing.T) {
}
func TestPPIOListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newPPIOForTest("http://unused").ListModels(ctx, &APIConfig{})
if err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -489,6 +504,7 @@ func TestPPIOListModelsRequiresAPIKey(t *testing.T) {
}
func TestPPIOListModelsRejectsProviderError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newPPIOServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -568,6 +584,7 @@ func TestPPIOMissingRegionBaseURL(t *testing.T) {
}
func TestPPIOUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newPPIOForTest("http://unused")
if _, err := m.Balance(ctx, nil); err == nil || !strings.Contains(err.Error(), "no such method") {
diff --git a/internal/entity/models/provider_response_usage_test.go b/internal/entity/models/provider_response_usage_test.go
index cca2e6b1b6..3e3cd5a9db 100644
--- a/internal/entity/models/provider_response_usage_test.go
+++ b/internal/entity/models/provider_response_usage_test.go
@@ -24,6 +24,7 @@ import (
)
func TestProviderLocalChatResponsesExposeUsage(t *testing.T) {
+ withSSRFBypass(t)
type providerCase struct {
name string
path string
diff --git a/internal/entity/models/qiniu.go b/internal/entity/models/qiniu.go
index 11e70e299e..d0d87eab83 100644
--- a/internal/entity/models/qiniu.go
+++ b/internal/entity/models/qiniu.go
@@ -37,7 +37,7 @@ func NewQiniuModel(baseURL map[string]string, urlSuffix URLSuffix) *QiniuModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/qiniu_test.go b/internal/entity/models/qiniu_test.go
index 0181d913ab..a69313bd8d 100644
--- a/internal/entity/models/qiniu_test.go
+++ b/internal/entity/models/qiniu_test.go
@@ -24,6 +24,7 @@ import (
)
func TestQiniuToolCalls(t *testing.T) {
+ withSSRFBypass(t)
newDriver := func(baseURL string) ModelDriver {
return NewQiniuModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "chat/completions"})
}
@@ -36,6 +37,7 @@ func TestQiniuToolCalls(t *testing.T) {
}
func TestQiniuChatStreamRejectsTruncatedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -52,6 +54,7 @@ func TestQiniuChatStreamRejectsTruncatedResponse(t *testing.T) {
}
func TestQiniuCheckConnectionUsesListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -75,6 +78,7 @@ func TestQiniuCheckConnectionUsesListModels(t *testing.T) {
}
func TestQiniuCheckConnectionPropagatesListModelsError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"invalid api key"}`, http.StatusUnauthorized)
diff --git a/internal/entity/models/ragcon.go b/internal/entity/models/ragcon.go
index cad87c130d..7910c67226 100644
--- a/internal/entity/models/ragcon.go
+++ b/internal/entity/models/ragcon.go
@@ -45,7 +45,7 @@ func NewRAGconModel(baseURL map[string]string, urlSuffix URLSuffix) *RAGconModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/ragcon_test.go b/internal/entity/models/ragcon_test.go
index 04b9414008..bd41323038 100644
--- a/internal/entity/models/ragcon_test.go
+++ b/internal/entity/models/ragcon_test.go
@@ -82,6 +82,7 @@ func TestRAGconFactory(t *testing.T) {
}
func TestRAGconChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
@@ -133,6 +134,7 @@ func TestRAGconChatHappyPath(t *testing.T) {
}
func TestRAGconChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newRAGconForTest("http://unused").ChatWithMessages(
ctx,
@@ -147,6 +149,7 @@ func TestRAGconChatRequiresAPIKey(t *testing.T) {
}
func TestRAGconChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newRAGconForTest("http://unused").ChatWithMessages(
@@ -162,6 +165,7 @@ func TestRAGconChatRequiresMessages(t *testing.T) {
}
func TestRAGconChatSurfacesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -183,6 +187,7 @@ func TestRAGconChatSurfacesHTTPError(t *testing.T) {
}
func TestRAGconStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
@@ -239,6 +244,7 @@ func TestRAGconStreamHappyPath(t *testing.T) {
}
func TestRAGconStreamRejectsNilSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newRAGconForTest("http://unused").ChatStreamlyWithSender(
@@ -256,6 +262,7 @@ func TestRAGconStreamRejectsNilSender(t *testing.T) {
}
func TestRAGconEmbed(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/embeddings" {
@@ -282,6 +289,7 @@ func TestRAGconEmbed(t *testing.T) {
}
func TestRAGconRerank(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/rerank" {
@@ -311,6 +319,7 @@ func TestRAGconRerank(t *testing.T) {
}
func TestRAGconListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newRAGconServer(t, func(t *testing.T, r *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/models" {
@@ -335,6 +344,7 @@ func TestRAGconListModelsAndCheckConnection(t *testing.T) {
}
func TestRAGconTranscribeAudioPostsMultipart(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/audio/transcriptions" {
@@ -379,6 +389,7 @@ func TestRAGconTranscribeAudioPostsMultipart(t *testing.T) {
}
func TestRAGconAudioSpeechPostsJSON(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newRAGconServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/audio/speech" {
@@ -404,6 +415,7 @@ func TestRAGconAudioSpeechPostsJSON(t *testing.T) {
}
func TestRAGconUnsupportedMethodsReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
r := newRAGconForTest("http://unused")
model := "llama-4-maverick"
diff --git a/internal/entity/models/reasoning_family_provider_test.go b/internal/entity/models/reasoning_family_provider_test.go
index 37a9b21aaf..89dc3bdf28 100644
--- a/internal/entity/models/reasoning_family_provider_test.go
+++ b/internal/entity/models/reasoning_family_provider_test.go
@@ -47,6 +47,7 @@ func newReasoningFamilyChatServer(t *testing.T, handler func(t *testing.T, body
}
func TestGiteeChatExtractsQwenThinkingFromInlineContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newReasoningFamilyChatServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "qwen3-8b" {
@@ -87,6 +88,7 @@ func TestGiteeChatExtractsQwenThinkingFromInlineContent(t *testing.T) {
}
func TestSiliconflowChatExtractsProviderPrefixedQwenThinkingFromInlineContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newReasoningFamilyChatServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "qwen/qwen3-8b" {
diff --git a/internal/entity/models/replicate.go b/internal/entity/models/replicate.go
index 22f7be9327..c75a33cc55 100644
--- a/internal/entity/models/replicate.go
+++ b/internal/entity/models/replicate.go
@@ -42,7 +42,7 @@ func NewReplicateModel(baseURL map[string]string, urlSuffix URLSuffix) *Replicat
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/replicate_test.go b/internal/entity/models/replicate_test.go
index 489b86b33d..d4e80bbdbc 100644
--- a/internal/entity/models/replicate_test.go
+++ b/internal/entity/models/replicate_test.go
@@ -75,6 +75,7 @@ func TestReplicatePredictionEndpoint(t *testing.T) {
}
func TestReplicateOfficialChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models/meta/meta-llama-3-70b-instruct/predictions" {
t.Errorf("path=%s", r.URL.Path)
@@ -143,6 +144,7 @@ func TestReplicateOfficialChatHappyPath(t *testing.T) {
}
func TestReplicateCommunityChatUsesVersionEndpoint(t *testing.T) {
+ withSSRFBypass(t)
const version = "replicate/hello-world:5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/predictions" {
@@ -180,6 +182,7 @@ func TestReplicateCommunityChatUsesVersionEndpoint(t *testing.T) {
}
func TestReplicateChatPollsUntilSucceeded(t *testing.T) {
+ withSSRFBypass(t)
var getCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
@@ -223,6 +226,7 @@ func TestReplicateChatPollsUntilSucceeded(t *testing.T) {
}
func TestReplicateStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
var streamURL string
streamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Accept"); got != "text/event-stream" {
@@ -283,6 +287,7 @@ func TestReplicateStreamHappyPath(t *testing.T) {
}
func TestReplicateListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
@@ -315,6 +320,7 @@ func TestReplicateListModelsAndCheckConnection(t *testing.T) {
}
func TestReplicateUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newReplicateForTest("http://unused")
apiKey := "test-key"
diff --git a/internal/entity/models/siliconflow.go b/internal/entity/models/siliconflow.go
index ff85cabc83..c760c7d06c 100644
--- a/internal/entity/models/siliconflow.go
+++ b/internal/entity/models/siliconflow.go
@@ -42,7 +42,7 @@ func NewSiliconflowModel(baseURL map[string]string, urlSuffix URLSuffix) *Silico
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/siliconflow_test.go b/internal/entity/models/siliconflow_test.go
index 3623e61ca3..f87cdb12f4 100644
--- a/internal/entity/models/siliconflow_test.go
+++ b/internal/entity/models/siliconflow_test.go
@@ -25,6 +25,7 @@ import (
)
func TestSiliconflowToolCalls(t *testing.T) {
+ withSSRFBypass(t)
newDriver := func(baseURL string) ModelDriver {
return NewSiliconflowModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "chat/completions"})
}
@@ -37,6 +38,7 @@ func TestSiliconflowToolCalls(t *testing.T) {
}
func TestSiliconflowChatRejectsMissingContent(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"choices":[{"message":{}}]}`))
}))
@@ -51,6 +53,7 @@ func TestSiliconflowChatRejectsMissingContent(t *testing.T) {
}
func TestSiliconflowChatWithMessagesExtractsResponseAndUsage(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-siliconflow",
@@ -109,6 +112,7 @@ func TestSiliconflowChatWithMessagesExtractsResponseAndUsage(t *testing.T) {
}
func TestSiliconflowChatStreamlyWithSenderCollectsUsage(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requestBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
diff --git a/internal/entity/models/sse_scanner_buffer_test.go b/internal/entity/models/sse_scanner_buffer_test.go
index 89a2b1410b..566807d395 100644
--- a/internal/entity/models/sse_scanner_buffer_test.go
+++ b/internal/entity/models/sse_scanner_buffer_test.go
@@ -35,6 +35,7 @@ func largeSSEStreamServer(t *testing.T, content string) *httptest.Server {
}
func TestChatStreamLargeChunkNotTruncated(t *testing.T) {
+ withSSRFBypass(t)
// 128KB content delta: comfortably past the 64KB default so the bare
// scanner would fail, well under the 1MB raised cap so the fix succeeds.
const big = 128 * 1024
diff --git a/internal/entity/models/stepfun.go b/internal/entity/models/stepfun.go
index bbfa3990f1..f4f863708f 100644
--- a/internal/entity/models/stepfun.go
+++ b/internal/entity/models/stepfun.go
@@ -39,7 +39,7 @@ func NewStepFunModel(baseURL map[string]string, urlSuffix URLSuffix) *StepFunMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/stepfun_test.go b/internal/entity/models/stepfun_test.go
index 6e3cac97f0..e3b4b4bd80 100644
--- a/internal/entity/models/stepfun_test.go
+++ b/internal/entity/models/stepfun_test.go
@@ -114,6 +114,7 @@ func TestStepFunNewInstancePreservesConfig(t *testing.T) {
}
func TestStepFunChatParsesUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -161,6 +162,7 @@ func TestStepFunChatParsesUsage(t *testing.T) {
}
func TestStepFunChatParsesExtendedUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -214,6 +216,7 @@ func TestStepFunChatParsesExtendedUsage(t *testing.T) {
}
func TestStepFunChatFallsBackTotalTokens(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -254,6 +257,7 @@ func TestStepFunChatFallsBackTotalTokens(t *testing.T) {
}
func TestStepFunChatNilUsageWhenAllZero(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -292,6 +296,7 @@ func TestStepFunChatNilUsageWhenAllZero(t *testing.T) {
}
func TestStepFunChatExtractsReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -334,6 +339,7 @@ func TestStepFunChatExtractsReasoning(t *testing.T) {
}
func TestStepFunChatFallsBackToReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -376,6 +382,7 @@ func TestStepFunChatFallsBackToReasoningContent(t *testing.T) {
}
func TestStepFunChatAcceptsReasoningOnlyResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -421,6 +428,7 @@ func TestStepFunChatAcceptsReasoningOnlyResponse(t *testing.T) {
}
func TestStepFunStreamParsesUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+
@@ -448,6 +456,7 @@ func TestStepFunStreamParsesUsage(t *testing.T) {
}
func TestStepFunStreamNullUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// StepFun may send usage: null on intermediate chunks.
srv := newStepFunSSEServer(t, "/v1/chat/completions",
@@ -476,6 +485,7 @@ func TestStepFunStreamNullUsage(t *testing.T) {
}
func TestStepFunStreamExtractsReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -516,6 +526,7 @@ func TestStepFunStreamExtractsReasoning(t *testing.T) {
}
func TestStepFunStreamFallsBackToReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"reasoning_content":"ds-style reasoning"}}]}`+"\n"+
@@ -546,6 +557,7 @@ func TestStepFunStreamFallsBackToReasoningContent(t *testing.T) {
}
func TestStepFunStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -585,6 +597,7 @@ func TestStepFunStreamHappyPath(t *testing.T) {
}
func TestStepFunStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
apiKey := "test-key"
@@ -597,6 +610,7 @@ func TestStepFunStreamRequiresSender(t *testing.T) {
}
func TestStepFunStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"delta":{"content":"half"}}]}`+"\n",
@@ -615,6 +629,7 @@ func TestStepFunStreamFailsWithoutTerminal(t *testing.T) {
}
func TestStepFunChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
_, err := m.ChatWithMessages(ctx, "step-3.7-flash",
@@ -626,6 +641,7 @@ func TestStepFunChatRequiresAPIKey(t *testing.T) {
}
func TestStepFunChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
apiKey := "test-key"
@@ -636,6 +652,7 @@ func TestStepFunChatRequiresMessages(t *testing.T) {
}
func TestStepFunChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -654,6 +671,7 @@ func TestStepFunChatRejectsHTTPError(t *testing.T) {
}
func TestStepFunChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "step-3.7-flash" {
@@ -704,6 +722,7 @@ func TestStepFunChatHappyPath(t *testing.T) {
}
func TestStepFunChatSupportsToolCalls(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var requestBody map[string]interface{}
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
@@ -783,6 +802,7 @@ func TestStepFunChatSupportsToolCalls(t *testing.T) {
}
func TestStepFunStreamDoesNotSendDoneAfterScannerError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunSSEServer(t, "/v1/chat/completions",
`data: `+strings.Repeat("x", 1024*1024+1)+"\n",
@@ -810,6 +830,7 @@ func TestStepFunStreamDoesNotSendDoneAfterScannerError(t *testing.T) {
}
func TestStepFunChatRejectsMalformedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -833,6 +854,7 @@ func TestStepFunChatRejectsMalformedResponse(t *testing.T) {
}
func TestStepFunStreamRejectsMalformedFrame(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunSSEServer(t, "/v1/chat/completions",
`data: {"choices":[{"index":0,"delta":{"content":"ok"}}]}`+"\n"+
@@ -852,6 +874,7 @@ func TestStepFunStreamRejectsMalformedFrame(t *testing.T) {
}
func TestStepFunListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/models", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -887,6 +910,7 @@ func TestStepFunListModelsAndCheckConnection(t *testing.T) {
}
func TestStepFunListModelsRejectsInvalidResponses(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newStepFunServer(t, "/v1/models", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -905,6 +929,7 @@ func TestStepFunListModelsRejectsInvalidResponses(t *testing.T) {
}
func TestStepFunListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
_, err := m.ListModels(ctx, &APIConfig{})
@@ -914,6 +939,7 @@ func TestStepFunListModelsRequiresAPIKey(t *testing.T) {
}
func TestStepFunEmbedReturnsNotImplemented(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
model := "x"
@@ -924,6 +950,7 @@ func TestStepFunEmbedReturnsNotImplemented(t *testing.T) {
}
func TestStepFunRerankReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
model := "x"
@@ -934,6 +961,7 @@ func TestStepFunRerankReturnsNoSuchMethod(t *testing.T) {
}
func TestStepFunBalanceReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
_, err := m.Balance(ctx, &APIConfig{})
@@ -943,6 +971,7 @@ func TestStepFunBalanceReturnsNoSuchMethod(t *testing.T) {
}
func TestStepFunAudioOCRReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newStepFunForTest("http://unused")
model := "x"
diff --git a/internal/entity/models/timeout_test.go b/internal/entity/models/timeout_test.go
index d41e3bbcfc..f2b66e70f2 100644
--- a/internal/entity/models/timeout_test.go
+++ b/internal/entity/models/timeout_test.go
@@ -59,6 +59,7 @@ func newTimeoutTestGroq(baseURL string) *GroqModel {
// nonStreamCallTimeout (the old 120s-wall footgun, just relocated), the
// context would cancel mid-stream and this test would fail.
func TestStreamNotTruncatedByNonStreamTimeout(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Stream emits for ~240ms, far past the 60ms non-stream deadline but well
// inside the 10s stream deadline.
@@ -118,6 +119,7 @@ func TestStreamNotTruncatedByNonStreamTimeout(t *testing.T) {
// watchdog so a broken timeout surfaces as a direct test failure
// rather than relying on the package's global test timeout.
func TestNonStreamHonorsShortDeadline(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
withTestTimeouts(t, 100*time.Millisecond, 10*time.Second)
diff --git a/internal/entity/models/togetherai.go b/internal/entity/models/togetherai.go
index b6ca986f6b..5511958fc1 100644
--- a/internal/entity/models/togetherai.go
+++ b/internal/entity/models/togetherai.go
@@ -41,7 +41,7 @@ func NewTogetherAIModel(baseURL map[string]string, urlSuffix URLSuffix) *Togethe
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/togetherai_test.go b/internal/entity/models/togetherai_test.go
index 6ea82d6717..7e648cc0f9 100644
--- a/internal/entity/models/togetherai_test.go
+++ b/internal/entity/models/togetherai_test.go
@@ -60,6 +60,7 @@ func TestTogetherAIFactory(t *testing.T) {
}
func TestTogetherAIChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
@@ -111,6 +112,7 @@ func TestTogetherAIChatHappyPath(t *testing.T) {
}
func TestTogetherAIChatForwardsReasoningEnabled(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "Qwen/Qwen3.5-9B" {
@@ -155,6 +157,7 @@ func TestTogetherAIChatForwardsReasoningEnabled(t *testing.T) {
}
func TestTogetherAIChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newTogetherAIForTest("http://unused").ChatWithMessages(ctx, "", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -164,6 +167,7 @@ func TestTogetherAIChatRequiresModelName(t *testing.T) {
}
func TestTogetherAIStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.URL.Path != "/chat/completions" {
@@ -214,6 +218,7 @@ func TestTogetherAIStreamHappyPath(t *testing.T) {
}
func TestTogetherAIStreamStopsOnRootFinishReason(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -246,6 +251,7 @@ func TestTogetherAIStreamStopsOnRootFinishReason(t *testing.T) {
}
func TestTogetherAIListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTogetherAIServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -276,6 +282,7 @@ func TestTogetherAIListModelsAndCheckConnection(t *testing.T) {
}
func TestTogetherAIUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newTogetherAIForTest("http://unused")
apiKey := "test-key"
diff --git a/internal/entity/models/tokenhub.go b/internal/entity/models/tokenhub.go
index 6524f49806..3887ada534 100644
--- a/internal/entity/models/tokenhub.go
+++ b/internal/entity/models/tokenhub.go
@@ -36,7 +36,7 @@ func NewTokenHubModel(baseURL map[string]string, urlSuffix URLSuffix) *TokenHubM
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/tokenhub_test.go b/internal/entity/models/tokenhub_test.go
index e91068d580..457797bb28 100644
--- a/internal/entity/models/tokenhub_test.go
+++ b/internal/entity/models/tokenhub_test.go
@@ -98,6 +98,7 @@ func TestTokenHubFactory(t *testing.T) {
}
func TestTokenHubChatWithMessagesForcesNonStreaming(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenHubServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["stream"] != false {
@@ -136,6 +137,7 @@ func TestTokenHubChatWithMessagesForcesNonStreaming(t *testing.T) {
}
func TestTokenHubChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newTokenHubForTest("http://unused").ChatWithMessages(ctx, "gpt-4o-mini", []Message{{Role: "user", Content: "x"}}, &APIConfig{}, nil, nil)
if err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -144,6 +146,7 @@ func TestTokenHubChatRequiresAPIKey(t *testing.T) {
}
func TestTokenHubChatRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newTokenHubForTest("http://unused").ChatWithMessages(ctx, " ", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil, nil)
@@ -153,6 +156,7 @@ func TestTokenHubChatRequiresModelName(t *testing.T) {
}
func TestTokenHubStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenHubSSEServer(t, "/chat/completions", strings.Join([]string{
`data: {"choices":[{"delta":{"reasoning_content":"thinking"}}]}`,
@@ -194,6 +198,7 @@ func TestTokenHubStreamHappyPath(t *testing.T) {
}
func TestTokenHubStreamRejectsFalseStreamConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
stream := false
@@ -212,6 +217,7 @@ func TestTokenHubStreamRejectsFalseStreamConfig(t *testing.T) {
}
func TestTokenHubStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newTokenHubForTest("http://unused").ChatStreamlyWithSender(
@@ -229,6 +235,7 @@ func TestTokenHubStreamRequiresSender(t *testing.T) {
}
func TestTokenHubStreamRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
err := newTokenHubForTest("http://unused").ChatStreamlyWithSender(
ctx,
@@ -245,6 +252,7 @@ func TestTokenHubStreamRequiresAPIKey(t *testing.T) {
}
func TestTokenHubStreamRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newTokenHubForTest("http://unused").ChatStreamlyWithSender(
@@ -262,6 +270,7 @@ func TestTokenHubStreamRequiresModelName(t *testing.T) {
}
func TestTokenHubEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenHubServer(t, http.MethodPost, "/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "text-embedding-3-small" {
@@ -294,6 +303,7 @@ func TestTokenHubEmbedHappyPath(t *testing.T) {
}
func TestTokenHubEmbedValidatesInputs(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
if embeddings, err := newTokenHubForTest("http://unused").Embed(ctx, nil, nil, &APIConfig{ApiKey: &apiKey}, nil, nil); err != nil || len(embeddings) != 0 {
@@ -309,6 +319,7 @@ func TestTokenHubEmbedValidatesInputs(t *testing.T) {
}
func TestTokenHubListModelsHappyPathSkipsMalformedItems(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenHubServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -334,6 +345,7 @@ func TestTokenHubListModelsHappyPathSkipsMalformedItems(t *testing.T) {
}
func TestTokenHubListModelsValidatesResponseAndAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
if _, err := newTokenHubForTest("http://unused").ListModels(ctx, &APIConfig{}); err == nil || !strings.Contains(err.Error(), "api key is required") {
t.Fatalf("expected api-key error, got %v", err)
diff --git a/internal/entity/models/tokenpony.go b/internal/entity/models/tokenpony.go
index d87dda67fc..28b3bea594 100644
--- a/internal/entity/models/tokenpony.go
+++ b/internal/entity/models/tokenpony.go
@@ -38,7 +38,7 @@ func NewTokenPonyModel(baseURL map[string]string, urlSuffix URLSuffix) *TokenPon
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/tokenpony_test.go b/internal/entity/models/tokenpony_test.go
index d8b3674faf..a4cbd89581 100644
--- a/internal/entity/models/tokenpony_test.go
+++ b/internal/entity/models/tokenpony_test.go
@@ -92,6 +92,7 @@ func TestTokenPonyFactory(t *testing.T) {
}
func TestTokenPonyChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonyServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "qwen3-32b" {
@@ -140,6 +141,7 @@ func TestTokenPonyChatHappyPath(t *testing.T) {
}
func TestTokenPonyChatNoReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonyServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -168,6 +170,7 @@ func TestTokenPonyChatNoReasoning(t *testing.T) {
}
func TestTokenPonyChatRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newTokenPonyForTest("http://unused").ChatWithMessages(
ctx,
@@ -180,6 +183,7 @@ func TestTokenPonyChatRequiresAPIKey(t *testing.T) {
}
func TestTokenPonyChatRequiresMessages(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
_, err := newTokenPonyForTest("http://unused").ChatWithMessages(
@@ -190,6 +194,7 @@ func TestTokenPonyChatRequiresMessages(t *testing.T) {
}
func TestTokenPonyChatPropagatesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonyServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -209,6 +214,7 @@ func TestTokenPonyChatPropagatesHTTPError(t *testing.T) {
}
func TestTokenPonyStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonySSEServer(t, "/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -249,6 +255,7 @@ func TestTokenPonyStreamHappyPath(t *testing.T) {
}
func TestTokenPonyStreamSplitsReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonySSEServer(t, "/chat/completions",
`data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+
@@ -290,6 +297,7 @@ func TestTokenPonyStreamSplitsReasoning(t *testing.T) {
}
func TestTokenPonyStreamRejectsExplicitFalse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
stream := false
@@ -307,6 +315,7 @@ func TestTokenPonyStreamRejectsExplicitFalse(t *testing.T) {
}
func TestTokenPonyStreamRequiresSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
err := newTokenPonyForTest("http://unused").ChatStreamlyWithSender(
@@ -320,6 +329,7 @@ func TestTokenPonyStreamRequiresSender(t *testing.T) {
}
func TestTokenPonyStreamFailsWithoutTerminal(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonySSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"half"}}]}`+"\n",
@@ -339,6 +349,7 @@ func TestTokenPonyStreamFailsWithoutTerminal(t *testing.T) {
}
func TestTokenPonyStreamRejectsMalformedFrame(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonySSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+
@@ -359,6 +370,7 @@ func TestTokenPonyStreamRejectsMalformedFrame(t *testing.T) {
}
func TestTokenPonyStreamSurfacesUpstreamError(t *testing.T) {
+ withSSRFBypass(t)
srv := newTokenPonySSEServer(t, "/chat/completions",
`data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+
`data: {"error":{"message":"rate limit","type":"rate_limit_error"}}`+"\n",
@@ -382,6 +394,7 @@ func TestTokenPonyStreamSurfacesUpstreamError(t *testing.T) {
}
func TestTokenPonyListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonyServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -406,6 +419,7 @@ func TestTokenPonyListModelsHappyPath(t *testing.T) {
}
func TestTokenPonyListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newTokenPonyForTest("http://unused").ListModels(ctx, &APIConfig{})
if err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -414,6 +428,7 @@ func TestTokenPonyListModelsRequiresAPIKey(t *testing.T) {
}
func TestTokenPonyCheckConnectionDelegatesToListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonyServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -429,6 +444,7 @@ func TestTokenPonyCheckConnectionDelegatesToListModels(t *testing.T) {
}
func TestTokenPonyCheckConnectionPropagatesError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newTokenPonyServer(t, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -444,6 +460,7 @@ func TestTokenPonyCheckConnectionPropagatesError(t *testing.T) {
}
func TestTokenPonyBaseURLForRegionUnknown(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newTokenPonyForTest("http://unused")
apiKey := "test-key"
@@ -455,6 +472,7 @@ func TestTokenPonyBaseURLForRegionUnknown(t *testing.T) {
}
func TestTokenPonyEmbedReturnsNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := "x"
_, err := newTokenPonyForTest("http://unused").Embed(ctx, &model, []string{"a"}, &APIConfig{}, nil, nil)
@@ -464,6 +482,7 @@ func TestTokenPonyEmbedReturnsNoSuchMethod(t *testing.T) {
}
func TestTokenPonyAudioOCRReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newTokenPonyForTest("http://unused")
model := "x"
diff --git a/internal/entity/models/tool_call_test.go b/internal/entity/models/tool_call_test.go
index c13a536dbb..056c5a90be 100644
--- a/internal/entity/models/tool_call_test.go
+++ b/internal/entity/models/tool_call_test.go
@@ -24,6 +24,7 @@ import (
)
func testNonStreamingToolCall(t *testing.T, modelName, path string, newDriver func(string) ModelDriver) {
+ withSSRFBypass(t)
ctx := t.Context()
t.Helper()
var requestBody map[string]interface{}
@@ -67,6 +68,7 @@ func testNonStreamingToolCall(t *testing.T, modelName, path string, newDriver fu
}
func testStreamingToolCall(t *testing.T, modelName, path string, newDriver func(string) ModelDriver) {
+ withSSRFBypass(t)
ctx := t.Context()
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/entity/models/upstage.go b/internal/entity/models/upstage.go
index 7044c744e9..b89f577825 100644
--- a/internal/entity/models/upstage.go
+++ b/internal/entity/models/upstage.go
@@ -38,7 +38,7 @@ func NewUpstageModel(baseURL map[string]string, urlSuffix URLSuffix) *UpstageMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/upstage_test.go b/internal/entity/models/upstage_test.go
index 86d5df3cd0..ebfe158161 100644
--- a/internal/entity/models/upstage_test.go
+++ b/internal/entity/models/upstage_test.go
@@ -23,6 +23,7 @@ func newUpstageForTest(baseURL string) *UpstageModel {
// ---------- reasoning_effort / reasoning field ----------
func TestUpstageChatPropagatesReasoningEffort(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Per https://console.upstage.ai/api/docs/for-agents/raw, Upstage
// Solar models accept `reasoning_effort: minimal|low|medium|high`.
@@ -52,6 +53,7 @@ func TestUpstageChatPropagatesReasoningEffort(t *testing.T) {
}
func TestUpstageChatOmitsReasoningEffortWhenUnset(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// If the caller does not opt in, the field must NOT be sent. Sending
// "minimal" by default would silently change behavior for downstream
@@ -81,6 +83,7 @@ func TestUpstageChatOmitsReasoningEffortWhenUnset(t *testing.T) {
}
func TestUpstageStreamPropagatesReasoningEffort(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -113,6 +116,7 @@ func TestUpstageStreamPropagatesReasoningEffort(t *testing.T) {
}
func TestUpstageChatExtractsReasoningField(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Per the Upstage docs: when reasoning_effort is high|medium for
// solar-pro3 (or high for solar-pro2), the response's
@@ -143,6 +147,7 @@ func TestUpstageChatExtractsReasoningField(t *testing.T) {
}
func TestUpstageChatHandlesAbsentReasoning(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Models without reasoning (solar-mini, syn-pro) or low-effort
// requests return no `reasoning` field. The driver must leave
@@ -172,6 +177,7 @@ func TestUpstageChatHandlesAbsentReasoning(t *testing.T) {
// https://console.upstage.ai/api/chat) round-trips through the request
// body for both streaming and non-streaming paths.
func TestUpstageRequestBodyMatchesSolarAPIShape(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -221,6 +227,7 @@ func TestUpstageRequestBodyMatchesSolarAPIShape(t *testing.T) {
// ---------- Embed: duplicate / out-of-range / reorder ----------
func TestUpstageEmbedRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// A malformed upstream that repeats data[*].index would silently
// overwrite the earlier vector; the driver must fail loudly instead.
@@ -241,6 +248,7 @@ func TestUpstageEmbedRejectsDuplicateIndex(t *testing.T) {
}
func TestUpstageEmbedRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"data":[{"embedding":[1],"index":7}]}`)
@@ -257,6 +265,7 @@ func TestUpstageEmbedRejectsOutOfRangeIndex(t *testing.T) {
}
func TestUpstageEmbedHappyPathReordersByIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// Upstream returns vectors in shuffled order; driver must realign.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -290,6 +299,7 @@ func TestUpstageEmbedHappyPathReordersByIndex(t *testing.T) {
// reasoning_effort=high — both fields appear, sometimes in the same
// chunk and sometimes separately.
func TestUpstageStreamExtractsReasoningDelta(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -348,6 +358,7 @@ func TestUpstageStreamExtractsReasoningDelta(t *testing.T) {
// present: reasoning is forwarded first so a UI consuming both can
// render the chain-of-thought before the answer for that token.
func TestUpstageStreamReasoningChunksArriveBeforeContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -396,6 +407,7 @@ func TestUpstageStreamReasoningChunksArriveBeforeContent(t *testing.T) {
// non-reasoning models (solar-mini, solar-pro2 with no reasoning_effort)
// emit only delta.content. The driver must not regress on them.
func TestUpstageStreamWithoutReasoningStillWorks(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
diff --git a/internal/entity/models/vllm.go b/internal/entity/models/vllm.go
index efd81366a6..f7bbef29c9 100644
--- a/internal/entity/models/vllm.go
+++ b/internal/entity/models/vllm.go
@@ -39,7 +39,7 @@ func NewVllmModel(baseURL map[string]string, urlSuffix URLSuffix) *VllmModel {
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/vllm_rerank_test.go b/internal/entity/models/vllm_rerank_test.go
index d7d4f3db12..e4733bdb8c 100644
--- a/internal/entity/models/vllm_rerank_test.go
+++ b/internal/entity/models/vllm_rerank_test.go
@@ -50,6 +50,7 @@ func newVllmModelForTest(baseURL string) *VllmModel {
}
func TestVllmRerankHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "BAAI/bge-reranker-v2-m3" {
@@ -109,6 +110,7 @@ func TestVllmRerankHappyPath(t *testing.T) {
}
func TestVllmRerankTopNClamp(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["top_n"] != float64(2) {
@@ -134,6 +136,7 @@ func TestVllmRerankTopNClamp(t *testing.T) {
}
func TestVllmRerankEmptyDocuments(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := newVllmModelForTest("http://unused")
apiKey := "test-key"
@@ -151,6 +154,7 @@ func TestVllmRerankEmptyDocuments(t *testing.T) {
// no APIConfig.ApiKey is configured. This diverges from the NVIDIA driver
// which requires an API key.
func TestVllmRerankWithoutAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVllmRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -173,6 +177,7 @@ func TestVllmRerankWithoutAPIKey(t *testing.T) {
}
func TestVllmRerankRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
model := newVllmModelForTest("http://unused")
apiKey := "test-key"
@@ -183,6 +188,7 @@ func TestVllmRerankRequiresModelName(t *testing.T) {
}
func TestVllmRerankRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusInternalServerError)
@@ -200,6 +206,7 @@ func TestVllmRerankRejectsHTTPError(t *testing.T) {
}
func TestVllmRerankRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVllmRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
diff --git a/internal/entity/models/volcengine.go b/internal/entity/models/volcengine.go
index 013c1eed1b..12e64cdd14 100644
--- a/internal/entity/models/volcengine.go
+++ b/internal/entity/models/volcengine.go
@@ -39,7 +39,7 @@ func NewVolcEngine(baseURL map[string]string, urlSuffix URLSuffix) *VolcEngine {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/volcengine_test.go b/internal/entity/models/volcengine_test.go
index d2e88a75f0..1863fb2da1 100644
--- a/internal/entity/models/volcengine_test.go
+++ b/internal/entity/models/volcengine_test.go
@@ -56,6 +56,7 @@ func TestVolcEngineConfigDeclaresModelsSuffix(t *testing.T) {
}
func TestVolcEngineListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVolcEngineServer(t, func(t *testing.T, r *http.Request, w http.ResponseWriter) {
if r.Method != http.MethodGet {
@@ -89,6 +90,7 @@ func TestVolcEngineListModelsHappyPath(t *testing.T) {
}
func TestVolcEngineListModelsRejectsProviderError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVolcEngineServer(t, func(t *testing.T, r *http.Request, w http.ResponseWriter) {
http.Error(w, "bad key", http.StatusUnauthorized)
@@ -103,6 +105,7 @@ func TestVolcEngineListModelsRejectsProviderError(t *testing.T) {
}
func TestVolcEngineListModelsRequiresModelsSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
model := NewVolcEngine(map[string]string{"default": "http://unused"}, URLSuffix{})
@@ -114,6 +117,7 @@ func TestVolcEngineListModelsRequiresModelsSuffix(t *testing.T) {
}
func TestVolcEngineChatStreamSupportsMaxEffortAndUsage(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVolcEngineServer(t, func(t *testing.T, r *http.Request, w http.ResponseWriter) {
var body map[string]interface{}
@@ -155,6 +159,7 @@ func TestVolcEngineChatStreamSupportsMaxEffortAndUsage(t *testing.T) {
}
func TestVolcEngineChatStreamRejectsTruncatedResponse(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVolcEngineServer(t, func(t *testing.T, _ *http.Request, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
diff --git a/internal/entity/models/voyage.go b/internal/entity/models/voyage.go
index 25d1a86db2..fce90b515c 100644
--- a/internal/entity/models/voyage.go
+++ b/internal/entity/models/voyage.go
@@ -38,7 +38,7 @@ func NewVoyageModel(baseURL map[string]string, urlSuffix URLSuffix) *VoyageModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/voyage_test.go b/internal/entity/models/voyage_test.go
index 3b0b255ff5..5f5a7fd853 100644
--- a/internal/entity/models/voyage_test.go
+++ b/internal/entity/models/voyage_test.go
@@ -66,6 +66,7 @@ func TestVoyageNewModelWithCustomDefaultTransport(t *testing.T) {
}
func TestVoyageEmbedHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "voyage-3.5" {
@@ -103,6 +104,7 @@ func TestVoyageEmbedHappyPath(t *testing.T) {
// "dimensions" returns "Argument 'dimensions' is not supported by our
// API"), so this name matters and must not regress.
func TestVoyageEmbedPropagatesOutputDimension(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if got, ok := body["output_dimension"].(float64); !ok || got != 256 {
@@ -133,6 +135,7 @@ func TestVoyageEmbedPropagatesOutputDimension(t *testing.T) {
// would default the vector length, but only if we don't send the key
// at all (sending output_dimension: 0 is a 400).
func TestVoyageEmbedOmitsOutputDimensionWhenUnset(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if _, present := body["output_dimension"]; present {
@@ -154,6 +157,7 @@ func TestVoyageEmbedOmitsOutputDimensionWhenUnset(t *testing.T) {
}
func TestVoyageEmbedReordersByIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -181,6 +185,7 @@ func TestVoyageEmbedReordersByIndex(t *testing.T) {
}
func TestVoyageEmbedEmptyInputShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("Embed([]) made an unexpected HTTP call")
@@ -197,6 +202,7 @@ func TestVoyageEmbedEmptyInputShortCircuits(t *testing.T) {
}
func TestVoyageEmbedRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
v := newVoyageForTest("http://unused")
model := "voyage-3.5"
@@ -207,6 +213,7 @@ func TestVoyageEmbedRequiresAPIKey(t *testing.T) {
}
func TestVoyageEmbedRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
v := newVoyageForTest("http://unused")
apiKey := "test-key"
@@ -217,6 +224,7 @@ func TestVoyageEmbedRequiresModelName(t *testing.T) {
}
func TestVoyageEmbedRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -238,6 +246,7 @@ func TestVoyageEmbedRejectsDuplicateIndex(t *testing.T) {
}
func TestVoyageEmbedRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -258,6 +267,7 @@ func TestVoyageEmbedRejectsOutOfRangeIndex(t *testing.T) {
}
func TestVoyageEmbedRejectsMissingSlot(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/embeddings", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -278,6 +288,7 @@ func TestVoyageEmbedRejectsMissingSlot(t *testing.T) {
}
func TestVoyageRerankHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
// Voyage's request key is top_k (not top_n).
@@ -319,6 +330,7 @@ func TestVoyageRerankHappyPath(t *testing.T) {
}
func TestVoyageRerankTopKDefaultsToLenDocuments(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/rerank", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["top_k"] != float64(4) {
@@ -339,6 +351,7 @@ func TestVoyageRerankTopKDefaultsToLenDocuments(t *testing.T) {
}
func TestVoyageRerankEmptyDocuments(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
v := newVoyageForTest("http://unused")
apiKey := "test-key"
@@ -354,6 +367,7 @@ func TestVoyageRerankEmptyDocuments(t *testing.T) {
}
func TestVoyageRerankRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newVoyageServer(t, "/v1/rerank", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -375,6 +389,7 @@ func TestVoyageRerankRejectsOutOfRangeIndex(t *testing.T) {
}
func TestVoyageRerankRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
// A duplicate index would silently overwrite an earlier slot, which
// is the same failure mode Embed already guards against. Make sure
@@ -404,6 +419,7 @@ func TestVoyageRerankRejectsDuplicateIndex(t *testing.T) {
// (e.g. `.../v1//embeddings`). Rerank already trims, so Embed must
// trim too; CodeRabbit flagged the inconsistency.
func TestVoyageEmbedTrimsTrailingSlashInBaseURL(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var sawPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/entity/models/xai.go b/internal/entity/models/xai.go
index d0057b3476..b509ce3750 100644
--- a/internal/entity/models/xai.go
+++ b/internal/entity/models/xai.go
@@ -49,7 +49,7 @@ func NewXAIModel(baseURL map[string]string, urlSuffix URLSuffix) *XAIModel {
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/xai_test.go b/internal/entity/models/xai_test.go
index 25267917a2..6a3e46bff7 100644
--- a/internal/entity/models/xai_test.go
+++ b/internal/entity/models/xai_test.go
@@ -43,6 +43,7 @@ func TestXAIConfigDeclaresModelsSuffix(t *testing.T) {
}
func TestXAIListModelsHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -80,6 +81,7 @@ func TestXAIListModelsHappyPath(t *testing.T) {
}
func TestXAIListModelsRequiresAPIKey(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
_, err := newXAIForTest("http://unused").ListModels(ctx, &APIConfig{})
if err == nil || !strings.Contains(err.Error(), "api key is required") {
@@ -88,6 +90,7 @@ func TestXAIListModelsRequiresAPIKey(t *testing.T) {
}
func TestXAIListModelsRejectsProviderError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad key", http.StatusUnauthorized)
@@ -102,6 +105,7 @@ func TestXAIListModelsRejectsProviderError(t *testing.T) {
}
func TestXAICheckConnectionDelegatesToListModels(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/models" {
@@ -118,6 +122,7 @@ func TestXAICheckConnectionDelegatesToListModels(t *testing.T) {
}
func TestXAIListModelsRequiresModelsSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("ListModels should reject a missing models suffix before sending a request")
diff --git a/internal/entity/models/xiaomi.go b/internal/entity/models/xiaomi.go
index 82faf3cad0..23440b2051 100644
--- a/internal/entity/models/xiaomi.go
+++ b/internal/entity/models/xiaomi.go
@@ -40,7 +40,7 @@ func NewXiaomiModel(baseURL map[string]string, urlSuffix URLSuffix) *XiaomiModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/xiaomi_test.go b/internal/entity/models/xiaomi_test.go
index 31637cd684..b46f8b3972 100644
--- a/internal/entity/models/xiaomi_test.go
+++ b/internal/entity/models/xiaomi_test.go
@@ -80,6 +80,7 @@ func TestXiaomiNewModelWithCustomDefaultTransport(t *testing.T) {
}
func TestXiaomiChatHappyPath(t *testing.T) {
+ withSSRFBypass(t)
srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "mimo-v2.5-pro" {
t.Errorf("model=%v", body["model"])
@@ -130,6 +131,7 @@ func TestXiaomiChatHappyPath(t *testing.T) {
}
func TestXiaomiUsesEmptyRegionBaseURLOverride(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -157,6 +159,7 @@ func TestXiaomiUsesEmptyRegionBaseURLOverride(t *testing.T) {
}
func TestXiaomiAPIConfigBaseURLOverridesRegionMap(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXiaomiServer(t, "/override/chat", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -185,6 +188,7 @@ func TestXiaomiAPIConfigBaseURLOverridesRegionMap(t *testing.T) {
}
func TestXiaomiChatExtractsReasoningContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -209,6 +213,7 @@ func TestXiaomiChatExtractsReasoningContent(t *testing.T) {
}
func TestXiaomiChatRequiresInputs(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
m := newXiaomiForTest("http://unused")
@@ -224,6 +229,7 @@ func TestXiaomiChatRequiresInputs(t *testing.T) {
}
func TestXiaomiChatRejectsHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized)
@@ -239,6 +245,7 @@ func TestXiaomiChatRejectsHTTPError(t *testing.T) {
}
func TestXiaomiStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) {
if body["stream"] != true {
@@ -292,6 +299,7 @@ func TestXiaomiStreamHappyPath(t *testing.T) {
}
func TestXiaomiStreamHandlesCRLFFrames(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -327,6 +335,7 @@ func TestXiaomiStreamHandlesCRLFFrames(t *testing.T) {
}
func TestXiaomiStreamRejectsMalformedFrame(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXiaomiServer(t, "/v1/chat/completions", func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
@@ -343,6 +352,7 @@ func TestXiaomiStreamRejectsMalformedFrame(t *testing.T) {
}
func TestXiaomiUnsupportedMethods(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
m := newXiaomiForTest("http://unused")
model := "mimo-v2.5-pro"
diff --git a/internal/entity/models/xinference.go b/internal/entity/models/xinference.go
index 8ca806cea4..5647575a8c 100644
--- a/internal/entity/models/xinference.go
+++ b/internal/entity/models/xinference.go
@@ -63,7 +63,7 @@ func NewXinferenceModel(baseURL map[string]string, urlSuffix URLSuffix) *Xinfere
BaseURL: baseURL,
URLSuffix: urlSuffix,
AllowEmptyAPIKey: true,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(true),
},
}
}
diff --git a/internal/entity/models/xinference_test.go b/internal/entity/models/xinference_test.go
index 872c9564bc..3601c5b0ca 100644
--- a/internal/entity/models/xinference_test.go
+++ b/internal/entity/models/xinference_test.go
@@ -66,6 +66,7 @@ func TestXinferenceFactoryRoute(t *testing.T) {
}
func TestXinferenceChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
var seen map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -113,6 +114,7 @@ func TestXinferenceChatHappyPathNormalizesBaseURLAndOmitsEmptyAuth(t *testing.T)
}
func TestXinferenceChatSendsAuthHeaderWhenKeyProvided(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer sk-test" {
@@ -133,6 +135,7 @@ func TestXinferenceChatSendsAuthHeaderWhenKeyProvided(t *testing.T) {
}
func TestXinferenceChatExtractsReasoningFields(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"choices":[{"message":{
@@ -155,6 +158,7 @@ func TestXinferenceChatExtractsReasoningFields(t *testing.T) {
}
func TestXinferenceStreamHappyPath(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
@@ -210,6 +214,7 @@ func TestXinferenceStreamHappyPath(t *testing.T) {
}
func TestXinferenceStreamRejectsFalseStreamConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := newXinferenceForTest("http://unused")
stream := false
@@ -225,6 +230,7 @@ func TestXinferenceStreamRejectsFalseStreamConfig(t *testing.T) {
}
func TestXinferenceStreamCancelsOnIdle(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
withXinferenceIdleTimeout(t, 200*time.Millisecond)
@@ -256,6 +262,7 @@ func TestXinferenceStreamCancelsOnIdle(t *testing.T) {
}
func TestXinferenceListModelsAndCheckConnection(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
@@ -313,6 +320,7 @@ func newXinferenceEmbedServer(t *testing.T, handler func(t *testing.T, body map[
}
func TestXinferenceEmbedHappyPathAndOmitsEmptyAuth(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "bge-m3" {
@@ -346,6 +354,7 @@ func TestXinferenceEmbedHappyPathAndOmitsEmptyAuth(t *testing.T) {
}
func TestXinferenceEmbedSendsAuthWhenKeyConfigured(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
gotAuth := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -366,6 +375,7 @@ func TestXinferenceEmbedSendsAuthWhenKeyConfigured(t *testing.T) {
}
func TestXinferenceEmbedNormalizesBaseURLWithV1Suffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1]}]}`)
@@ -383,6 +393,7 @@ func TestXinferenceEmbedNormalizesBaseURLWithV1Suffix(t *testing.T) {
}
func TestXinferenceEmbedForwardsDimension(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["dimensions"] != float64(384) {
@@ -400,6 +411,7 @@ func TestXinferenceEmbedForwardsDimension(t *testing.T) {
}
func TestXinferenceEmbedRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1]},{"index":0,"embedding":[0.2]}]}`)
@@ -415,6 +427,7 @@ func TestXinferenceEmbedRejectsDuplicateIndex(t *testing.T) {
}
func TestXinferenceEmbedRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_, _ = io.WriteString(w, `{"data":[{"index":5,"embedding":[0.1]}]}`)
@@ -430,6 +443,7 @@ func TestXinferenceEmbedRejectsOutOfRangeIndex(t *testing.T) {
}
func TestXinferenceEmbedRejectsMissingIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceEmbedServer(t, func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
// Two inputs requested but only one returned — index 1 is missing.
@@ -446,6 +460,7 @@ func TestXinferenceEmbedRejectsMissingIndex(t *testing.T) {
}
func TestXinferenceEmbedEmptyTextsShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := newXinferenceForTest("http://unused")
model := "bge-m3"
@@ -459,6 +474,7 @@ func TestXinferenceEmbedEmptyTextsShortCircuits(t *testing.T) {
}
func TestXinferenceEmbedRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := newXinferenceForTest("http://unused")
_, err := x.Embed(ctx, nil, []string{"x"}, &APIConfig{}, nil, nil)
@@ -468,6 +484,7 @@ func TestXinferenceEmbedRequiresModelName(t *testing.T) {
}
func TestXinferenceEmbedSurfacesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@@ -484,6 +501,7 @@ func TestXinferenceEmbedSurfacesHTTPError(t *testing.T) {
}
func TestXinferenceEmbedRejectsMissingEmbeddingSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := NewXinferenceModel(
map[string]string{"default": "http://unused"},
@@ -497,6 +515,7 @@ func TestXinferenceEmbedRejectsMissingEmbeddingSuffix(t *testing.T) {
}
func TestXinferenceMissingBaseURLFailsClearly(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := NewXinferenceModel(map[string]string{}, URLSuffix{Chat: "v1/chat/completions"})
_, err := x.ChatWithMessages(ctx, "qwen2.5-instruct",
@@ -508,6 +527,7 @@ func TestXinferenceMissingBaseURLFailsClearly(t *testing.T) {
}
func TestXinferenceUnsupportedMethodsReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := newXinferenceForTest("http://unused")
model := "qwen2.5-instruct"
@@ -566,6 +586,7 @@ func newXinferenceRerankServer(t *testing.T, expectedAuth string, handler func(t
}
func TestXinferenceRerankHappyPathReordersByIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if body["model"] != "bge-reranker-v2-m3" {
@@ -608,6 +629,7 @@ func TestXinferenceRerankHappyPathReordersByIndex(t *testing.T) {
}
func TestXinferenceRerankNormalizesV1BaseURL(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceRerankServer(t, "Bearer test-key", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"results": []map[string]interface{}{}})
@@ -627,6 +649,7 @@ func TestXinferenceRerankNormalizesV1BaseURL(t *testing.T) {
}
func TestXinferenceRerankRespectsTopNConfig(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
if got := body["top_n"].(float64); got != 2 {
@@ -645,6 +668,7 @@ func TestXinferenceRerankRespectsTopNConfig(t *testing.T) {
}
func TestXinferenceRerankEmptyDocumentsShortCircuits(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := newXinferenceForTest("http://unused")
model := "bge-reranker-v2-m3"
@@ -658,6 +682,7 @@ func TestXinferenceRerankEmptyDocumentsShortCircuits(t *testing.T) {
}
func TestXinferenceRerankRequiresModelName(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := newXinferenceForTest("http://unused")
_, err := x.Rerank(ctx, nil, "q", []string{"a"}, &APIConfig{}, nil, nil)
@@ -667,6 +692,7 @@ func TestXinferenceRerankRequiresModelName(t *testing.T) {
}
func TestXinferenceRerankRejectsOutOfRangeIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -684,6 +710,7 @@ func TestXinferenceRerankRejectsOutOfRangeIndex(t *testing.T) {
}
func TestXinferenceRerankRejectsDuplicateIndex(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := newXinferenceRerankServer(t, "", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -704,6 +731,7 @@ func TestXinferenceRerankRejectsDuplicateIndex(t *testing.T) {
}
func TestXinferenceRerankSurfacesHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@@ -720,6 +748,7 @@ func TestXinferenceRerankSurfacesHTTPError(t *testing.T) {
}
func TestXinferenceRerankRejectsMissingRerankSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
x := NewXinferenceModel(
map[string]string{"default": "http://unused"},
diff --git a/internal/entity/models/xunfei.go b/internal/entity/models/xunfei.go
index 79caafcb28..b27ac957d4 100644
--- a/internal/entity/models/xunfei.go
+++ b/internal/entity/models/xunfei.go
@@ -35,7 +35,7 @@ func NewXunFeiModel(baseURL map[string]string, urlSuffix URLSuffix) *XunFeiModel
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/xunfei_test.go b/internal/entity/models/xunfei_test.go
index 781d719f13..b09bfa6a98 100644
--- a/internal/entity/models/xunfei_test.go
+++ b/internal/entity/models/xunfei_test.go
@@ -3,6 +3,7 @@ package models
import "testing"
func TestXunFeiUnsupportedMethodsReturnNoSuchMethod(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
driver := NewXunFeiModel(map[string]string{"default": "http://unused"}, URLSuffix{}).
NewInstance(map[string]string{"default": "http://unused"})
diff --git a/internal/entity/models/zhipu-ai.go b/internal/entity/models/zhipu-ai.go
index aa3de79fca..6b0d7b72be 100644
--- a/internal/entity/models/zhipu-ai.go
+++ b/internal/entity/models/zhipu-ai.go
@@ -42,7 +42,7 @@ func NewZhipuAIModel(baseURL map[string]string, urlSuffix URLSuffix) *ZhipuAIMod
baseModel: BaseModel{
BaseURL: baseURL,
URLSuffix: urlSuffix,
- httpClient: NewDriverHTTPClient(),
+ httpClient: NewDriverHTTPClient(false),
},
}
}
diff --git a/internal/entity/models/zhipu-ai_test.go b/internal/entity/models/zhipu-ai_test.go
index dad8fa7f46..9dc7f57a58 100644
--- a/internal/entity/models/zhipu-ai_test.go
+++ b/internal/entity/models/zhipu-ai_test.go
@@ -9,6 +9,7 @@ import (
)
func TestZhipuAIOCRFileSendsLayoutParsingRequest(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-ocr"
@@ -63,6 +64,7 @@ func TestZhipuAIOCRFileSendsLayoutParsingRequest(t *testing.T) {
}
func TestZhipuAIOCRFileEncodesContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-ocr"
@@ -89,6 +91,7 @@ func TestZhipuAIOCRFileEncodesContent(t *testing.T) {
}
func TestZhipuAIOCRFileDetectsPDFContent(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-ocr"
@@ -115,6 +118,7 @@ func TestZhipuAIOCRFileDetectsPDFContent(t *testing.T) {
}
func TestZhipuAIOCRFileValidation(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-ocr"
diff --git a/internal/entity/models/zhipu_ai_test.go b/internal/entity/models/zhipu_ai_test.go
index b0ae7e685a..6895acd923 100644
--- a/internal/entity/models/zhipu_ai_test.go
+++ b/internal/entity/models/zhipu_ai_test.go
@@ -33,6 +33,7 @@ func writeZhipuAITestAudio(t *testing.T) string {
}
func TestZhipuAITranscribeAudio(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -117,6 +118,7 @@ func TestZhipuAITranscribeAudio(t *testing.T) {
}
func TestZhipuAITranscribeAudioValidation(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-asr-2512"
@@ -145,6 +147,7 @@ func TestZhipuAITranscribeAudioValidation(t *testing.T) {
}
func TestZhipuAITranscribeAudioRequiresASRSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-asr-2512"
@@ -163,6 +166,7 @@ func TestZhipuAITranscribeAudioRequiresASRSuffix(t *testing.T) {
}
func TestZhipuAITranscribeAudioHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
@@ -180,6 +184,7 @@ func TestZhipuAITranscribeAudioHTTPError(t *testing.T) {
}
func TestZhipuAIAudioSpeech(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -250,6 +255,7 @@ func TestZhipuAIAudioSpeech(t *testing.T) {
}
func TestZhipuAIAudioSpeechWithSender(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
@@ -295,6 +301,7 @@ func TestZhipuAIAudioSpeechWithSender(t *testing.T) {
}
func TestZhipuAIAudioSpeechValidation(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-tts"
@@ -323,6 +330,7 @@ func TestZhipuAIAudioSpeechValidation(t *testing.T) {
}
func TestZhipuAIAudioSpeechRequiresTTSSuffix(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
apiKey := "test-key"
modelName := "glm-tts"
@@ -341,6 +349,7 @@ func TestZhipuAIAudioSpeechRequiresTTSSuffix(t *testing.T) {
}
func TestZhipuAIAudioSpeechHTTPError(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
@@ -358,6 +367,7 @@ func TestZhipuAIAudioSpeechHTTPError(t *testing.T) {
}
func TestZhipuAIChatStreamlyWithSenderCollectsToolCalls(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
diff --git a/internal/ingestion/component/parser_dispatch_test.go b/internal/ingestion/component/parser_dispatch_test.go
index b0e866c167..3a355682bd 100644
--- a/internal/ingestion/component/parser_dispatch_test.go
+++ b/internal/ingestion/component/parser_dispatch_test.go
@@ -544,6 +544,7 @@ func TestDispatch_PDFVisionJSON_PreservesEmptyPages(t *testing.T) {
}
func TestDispatch_PDFMinerUMarkdown_UsesConfiguredBackend(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/file_parse" {
buf := new(bytes.Buffer)
@@ -644,6 +645,7 @@ func (d *mineruTestDriver) ShowTask(ctx context.Context, taskID string, apiConfi
}
func TestDispatch_PDFPaddleOCRMarkdown_UsesConfiguredBackend(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/layout-parsing" {
http.NotFound(w, r)
@@ -684,6 +686,7 @@ func TestDispatch_PDFPaddleOCRMarkdown_UsesConfiguredBackend(t *testing.T) {
}
func TestDispatch_PDFDoclingMarkdown_UsesConfiguredBackend(t *testing.T) {
+ withSSRFBypass(t)
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
@@ -736,6 +739,7 @@ func TestDispatch_PDFDoclingMarkdown_UsesConfiguredBackend(t *testing.T) {
}
func TestDispatch_PDFOpenDataLoaderMarkdown_UsesConfiguredBackend(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/file_parse" {
http.NotFound(w, r)
@@ -768,6 +772,7 @@ func TestDispatch_PDFOpenDataLoaderMarkdown_UsesConfiguredBackend(t *testing.T)
}
func TestDispatch_PDFSoMarkMarkdown_UsesConfiguredBackend(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/parse/async":
@@ -802,6 +807,7 @@ func TestDispatch_PDFSoMarkMarkdown_UsesConfiguredBackend(t *testing.T) {
}
func TestDispatch_PDFTCADPMarkdown_UsesConfiguredBackend(t *testing.T) {
+ withSSRFBypass(t)
zipPayload := tcadpZipFixtureForComponent(t)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/ingestion/component/ssrf_test_helper_test.go b/internal/ingestion/component/ssrf_test_helper_test.go
new file mode 100644
index 0000000000..9bb883efe8
--- /dev/null
+++ b/internal/ingestion/component/ssrf_test_helper_test.go
@@ -0,0 +1,19 @@
+package component
+
+import (
+ "testing"
+
+ "ragflow/internal/utility"
+)
+
+// withSSRFBypass enables the test-only SSRF bypass for the duration of a test
+// and restores the previous value in t.Cleanup. Component tests exercise
+// parsing/dispatch logic against httptest servers bound to 127.0.0.1, which
+// the strict SSRF guard rejects. SSRF enforcement has its own dedicated
+// coverage in internal/utility/ssrf_test.go.
+func withSSRFBypass(t *testing.T) {
+ t.Helper()
+ prev := utility.AllowAnyHostForTest
+ utility.AllowAnyHostForTest = true
+ t.Cleanup(func() { utility.AllowAnyHostForTest = prev })
+}
diff --git a/internal/parser/parser/markdown_parser_test.go b/internal/parser/parser/markdown_parser_test.go
index 64afa19b56..049b9b12ad 100644
--- a/internal/parser/parser/markdown_parser_test.go
+++ b/internal/parser/parser/markdown_parser_test.go
@@ -203,6 +203,7 @@ func TestResolveMarkdownImage_NoImage(t *testing.T) {
}
func TestResolveMarkdownImage_HTTPImage(t *testing.T) {
+ withSSRFBypass(t)
// httptest servers bind loopback, which the SSRF guard rejects by
// default. Allow loopback for this test so the HTTP fetch path is
// exercised (production keeps ssrfAllowLoopback == false).
@@ -236,6 +237,7 @@ func TestFetchImageAsBase64_RejectsCredentials(t *testing.T) {
}
func TestFetchImageAsBase64_InvalidURL(t *testing.T) {
+ withSSRFBypass(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
diff --git a/internal/parser/parser/pdf_parser_docling.go b/internal/parser/parser/pdf_parser_docling.go
index 9d75aee3c2..48a2792229 100644
--- a/internal/parser/parser/pdf_parser_docling.go
+++ b/internal/parser/parser/pdf_parser_docling.go
@@ -88,7 +88,7 @@ func parsePDFWithDocling(ctx context.Context, filename string, data []byte, pars
var lastErr error
for _, candidate := range payloads {
url := baseURL + candidate.endpoint
- resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(), url, auth, candidate.body())
+ resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(false), url, auth, candidate.body())
if err != nil {
lastErr = fmt.Errorf("%s: %w", candidate.endpoint, err)
continue
diff --git a/internal/parser/parser/pdf_parser_docling_test.go b/internal/parser/parser/pdf_parser_docling_test.go
index 43ed17ef53..a6fa3bdd1c 100644
--- a/internal/parser/parser/pdf_parser_docling_test.go
+++ b/internal/parser/parser/pdf_parser_docling_test.go
@@ -11,6 +11,7 @@ import (
)
func TestPDFParser_ParseWithResult_DoclingChunkedMarkdownIntegration(t *testing.T) {
+ withSSRFBypass(t)
var requestCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -84,6 +85,7 @@ func TestPDFParser_ParseWithResult_DoclingChunkedMarkdownIntegration(t *testing.
}
func TestPDFParser_ParseWithResult_DoclingFallbackToStandardJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
var requestCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/parser/parser/pdf_parser_mineru_test.go b/internal/parser/parser/pdf_parser_mineru_test.go
index 85eab7fe33..b16af3b514 100644
--- a/internal/parser/parser/pdf_parser_mineru_test.go
+++ b/internal/parser/parser/pdf_parser_mineru_test.go
@@ -11,6 +11,7 @@ import (
)
func TestPDFParser_ParseWithResult_MinerUMarkdownIntegration(t *testing.T) {
+ withSSRFBypass(t)
var submitCalled atomic.Bool
var resultCalled atomic.Bool
@@ -100,6 +101,7 @@ func TestPDFParser_ParseWithResult_MinerUMarkdownIntegration(t *testing.T) {
}
func TestPDFParser_ParseWithResult_MinerUJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/file_parse":
diff --git a/internal/parser/parser/pdf_parser_opendataloader.go b/internal/parser/parser/pdf_parser_opendataloader.go
index 089327428a..5b08566a6c 100644
--- a/internal/parser/parser/pdf_parser_opendataloader.go
+++ b/internal/parser/parser/pdf_parser_opendataloader.go
@@ -41,7 +41,7 @@ func parsePDFWithOpenDataLoader(ctx context.Context, filename string, data []byt
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
- resp, err := models.NewDriverHTTPClient().Do(req)
+ resp, err := models.NewDriverHTTPClient(false).Do(req)
if err != nil {
return ParseResult{Err: fmt.Errorf("parser: OpenDataLoader submit: %w", err)}
}
diff --git a/internal/parser/parser/pdf_parser_opendataloader_test.go b/internal/parser/parser/pdf_parser_opendataloader_test.go
index 89e2e93d03..9d39683232 100644
--- a/internal/parser/parser/pdf_parser_opendataloader_test.go
+++ b/internal/parser/parser/pdf_parser_opendataloader_test.go
@@ -10,6 +10,7 @@ import (
)
func TestPDFParser_ParseWithResult_OpenDataLoaderJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/file_parse" {
http.NotFound(w, r)
@@ -84,6 +85,7 @@ func TestPDFParser_ParseWithResult_OpenDataLoaderJSONIntegration(t *testing.T) {
}
func TestPDFParser_ParseWithResult_OpenDataLoaderMarkdownFallback(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/file_parse" {
http.NotFound(w, r)
diff --git a/internal/parser/parser/pdf_parser_paddleocr_test.go b/internal/parser/parser/pdf_parser_paddleocr_test.go
index 3b7f8fae0e..bb42b4ac3d 100644
--- a/internal/parser/parser/pdf_parser_paddleocr_test.go
+++ b/internal/parser/parser/pdf_parser_paddleocr_test.go
@@ -11,6 +11,7 @@ import (
)
func TestPDFParser_ParseWithResult_PaddleOCRMarkdownIntegration(t *testing.T) {
+ withSSRFBypass(t)
var called atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -82,6 +83,7 @@ func TestPDFParser_ParseWithResult_PaddleOCRMarkdownIntegration(t *testing.T) {
}
func TestPDFParser_ParseWithResult_PaddleOCRJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/layout-parsing" {
http.NotFound(w, r)
diff --git a/internal/parser/parser/pdf_parser_somark.go b/internal/parser/parser/pdf_parser_somark.go
index a8ed7db7b7..b58395f548 100644
--- a/internal/parser/parser/pdf_parser_somark.go
+++ b/internal/parser/parser/pdf_parser_somark.go
@@ -84,7 +84,7 @@ func soMarkSubmit(baseURL, filename string, data []byte, parser *PDFParser, apiK
return "", fmt.Errorf("parser: SoMark request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
- resp, err := models.NewDriverHTTPClient().Do(req)
+ resp, err := models.NewDriverHTTPClient(false).Do(req)
if err != nil {
return "", fmt.Errorf("parser: SoMark submit: %w", err)
}
@@ -122,7 +122,7 @@ func soMarkPoll(baseURL, taskID, apiKey string) (map[string]any, error) {
return nil, fmt.Errorf("parser: SoMark poll request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- resp, err := models.NewDriverHTTPClient().Do(req)
+ resp, err := models.NewDriverHTTPClient(false).Do(req)
if err != nil {
return nil, fmt.Errorf("parser: SoMark poll: %w", err)
}
diff --git a/internal/parser/parser/pdf_parser_somark_test.go b/internal/parser/parser/pdf_parser_somark_test.go
index a86641c499..d94d326720 100644
--- a/internal/parser/parser/pdf_parser_somark_test.go
+++ b/internal/parser/parser/pdf_parser_somark_test.go
@@ -10,6 +10,7 @@ import (
)
func TestPDFParser_ParseWithResult_SoMarkJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
var submitSeen bool
var pollSeen bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -78,6 +79,7 @@ func TestPDFParser_ParseWithResult_SoMarkJSONIntegration(t *testing.T) {
}
func TestPDFParser_ParseWithResult_SoMarkMarkdownIntegration(t *testing.T) {
+ withSSRFBypass(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/parse/async":
@@ -123,6 +125,7 @@ func TestSoMarkBlockToItem_DropsHeaderByDefault(t *testing.T) {
}
func TestSoMarkSubmitMultipartShape(t *testing.T) {
+ withSSRFBypass(t)
var form multipart.Form
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseMultipartForm(1 << 20)
diff --git a/internal/parser/parser/pdf_parser_tcadp.go b/internal/parser/parser/pdf_parser_tcadp.go
index 126d9cd99e..1284e2699a 100644
--- a/internal/parser/parser/pdf_parser_tcadp.go
+++ b/internal/parser/parser/pdf_parser_tcadp.go
@@ -40,7 +40,7 @@ func parsePDFWithTCADP(filename string, data []byte, parser *PDFParser) ParseRes
"MarkdownImageResponseType": parser.TCADPMarkdownImageResponseType,
},
}
- resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody)
+ resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(false), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody)
if err != nil {
return ParseResult{Err: fmt.Errorf("parser: TCADP submit: %w", err)}
}
@@ -68,7 +68,7 @@ func parsePDFWithTCADP(filename string, data []byte, parser *PDFParser) ParseRes
if auth := bearer(apiKey); auth != "" {
downloadReq.Header.Set("Authorization", auth)
}
- downloadResp, err := models.NewDriverHTTPClient().Do(downloadReq)
+ downloadResp, err := models.NewDriverHTTPClient(false).Do(downloadReq)
if err != nil {
return ParseResult{Err: fmt.Errorf("parser: TCADP download: %w", err)}
}
diff --git a/internal/parser/parser/pdf_parser_tcadp_test.go b/internal/parser/parser/pdf_parser_tcadp_test.go
index fae95fbe97..bf517546a7 100644
--- a/internal/parser/parser/pdf_parser_tcadp_test.go
+++ b/internal/parser/parser/pdf_parser_tcadp_test.go
@@ -10,6 +10,7 @@ import (
)
func TestPDFParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
zipPayload := tcadpZipFixture(t)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -47,6 +48,7 @@ func TestPDFParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
}
func TestPDFParser_ParseWithResult_TCADPMarkdownIntegration(t *testing.T) {
+ withSSRFBypass(t)
zipPayload := tcadpZipFixture(t)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/parser/parser/ppt_parser_cgo_test.go b/internal/parser/parser/ppt_parser_cgo_test.go
index 81591d0142..9cab23d9dd 100644
--- a/internal/parser/parser/ppt_parser_cgo_test.go
+++ b/internal/parser/parser/ppt_parser_cgo_test.go
@@ -135,6 +135,7 @@ type tcadpPresentationParser interface {
}
func testPresentationTCADPIntegration(t *testing.T, p tcadpPresentationParser, wantFileType, filename string) {
+ withSSRFBypass(t)
t.Helper()
zipPayload := tcadpZipFixture(t)
var gotFileType string
@@ -189,6 +190,7 @@ func testPresentationTCADPIntegration(t *testing.T, p tcadpPresentationParser, w
// from the TCADP download endpoint is surfaced as an explicit error rather
// than parsed as a (malformed) ZIP artifact.
func TestPPTXParser_TCADPDownloadHTTPError(t *testing.T) {
+ withSSRFBypass(t)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
diff --git a/internal/parser/parser/pptx_tcadp.go b/internal/parser/parser/pptx_tcadp.go
index 47620ffa72..ad97d0a4c8 100644
--- a/internal/parser/parser/pptx_tcadp.go
+++ b/internal/parser/parser/pptx_tcadp.go
@@ -45,7 +45,7 @@ func parsePresentationWithTCADP(ctx context.Context, filename string, data []byt
"MarkdownImageResponseType": markdownImageResponseType,
},
}
- resp, err := models.PostJSONRequest(ctx, models.NewDriverHTTPClient(),
+ resp, err := models.PostJSONRequest(ctx, models.NewDriverHTTPClient(false),
strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody)
if err != nil {
return ParseResult{Err: fmt.Errorf("parser: TCADP submit: %w", err)}
@@ -75,7 +75,7 @@ func parsePresentationWithTCADP(ctx context.Context, filename string, data []byt
if auth := bearer(apiKey); auth != "" {
downloadReq.Header.Set("Authorization", auth)
}
- downloadResp, err := models.NewDriverHTTPClient().Do(downloadReq)
+ downloadResp, err := models.NewDriverHTTPClient(false).Do(downloadReq)
if err != nil {
return ParseResult{Err: fmt.Errorf("parser: TCADP download: %w", err)}
}
diff --git a/internal/parser/parser/ssrf_test_helper_test.go b/internal/parser/parser/ssrf_test_helper_test.go
new file mode 100644
index 0000000000..a0b76f4604
--- /dev/null
+++ b/internal/parser/parser/ssrf_test_helper_test.go
@@ -0,0 +1,19 @@
+package parser
+
+import (
+ "testing"
+
+ "ragflow/internal/utility"
+)
+
+// withSSRFBypass enables the test-only SSRF bypass for the duration of a test
+// and restores the previous value in t.Cleanup. Parser tests exercise parsing
+// logic against httptest servers bound to 127.0.0.1, which the strict SSRF
+// guard rejects. SSRF enforcement has its own dedicated coverage in
+// internal/utility/ssrf_test.go.
+func withSSRFBypass(t *testing.T) {
+ t.Helper()
+ prev := utility.AllowAnyHostForTest
+ utility.AllowAnyHostForTest = true
+ t.Cleanup(func() { utility.AllowAnyHostForTest = prev })
+}
diff --git a/internal/parser/parser/xls_tcadp.go b/internal/parser/parser/xls_tcadp.go
index e0c31e0972..9468450161 100644
--- a/internal/parser/parser/xls_tcadp.go
+++ b/internal/parser/parser/xls_tcadp.go
@@ -38,7 +38,7 @@ func parseSpreadsheetWithTCADP(filename string, data []byte, fileType string, tc
"MarkdownImageResponseType": markdownImageResponseType,
},
}
- resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody)
+ resp, err := models.PostJSONRequest(context.Background(), models.NewDriverHTTPClient(false), strings.TrimRight(baseURL, "/")+"/reconstruct_document", bearer(apiKey), requestBody)
if err != nil {
return ParseResult{Err: fmt.Errorf("parser: TCADP submit: %w", err)}
}
@@ -66,7 +66,7 @@ func parseSpreadsheetWithTCADP(filename string, data []byte, fileType string, tc
if auth := bearer(apiKey); auth != "" {
downloadReq.Header.Set("Authorization", auth)
}
- downloadResp, err := models.NewDriverHTTPClient().Do(downloadReq)
+ downloadResp, err := models.NewDriverHTTPClient(false).Do(downloadReq)
if err != nil {
return ParseResult{Err: fmt.Errorf("parser: TCADP download: %w", err)}
}
diff --git a/internal/parser/parser/xls_tcadp_test.go b/internal/parser/parser/xls_tcadp_test.go
index 111ca4bb00..96b5a8bc73 100644
--- a/internal/parser/parser/xls_tcadp_test.go
+++ b/internal/parser/parser/xls_tcadp_test.go
@@ -7,6 +7,7 @@ import (
)
func TestXLSXParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
zipPayload := tcadpZipFixture(t)
var server *httptest.Server
@@ -45,6 +46,7 @@ func TestXLSXParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
}
func TestXLSParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
zipPayload := tcadpZipFixture(t)
var server *httptest.Server
@@ -80,6 +82,7 @@ func TestXLSParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
}
func TestCSVParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
zipPayload := tcadpZipFixture(t)
var server *httptest.Server
@@ -112,6 +115,7 @@ func TestCSVParser_ParseWithResult_TCADPJSONIntegration(t *testing.T) {
}
func TestXLSXParser_ParseWithResult_TCADPMarkdownIntegration(t *testing.T) {
+ withSSRFBypass(t)
ctx := t.Context()
zipPayload := tcadpZipFixture(t)
var server *httptest.Server
diff --git a/internal/utility/ssrf.go b/internal/utility/ssrf.go
index c271dda88c..60cc256444 100644
--- a/internal/utility/ssrf.go
+++ b/internal/utility/ssrf.go
@@ -172,6 +172,31 @@ func allZero(b []byte) bool {
return true
}
+// AssertURLSchemeSafe is a lenient SSRF guard for drivers that may legitimately
+// target private networks or loopback addresses (e.g. self-hosted Ollama, vLLM,
+// Xinference). It only rejects dangerous schemes and empty hosts; it does not
+// resolve DNS and does not require public routability. Use this ONLY for
+// local-inference model drivers — cloud-hosted drivers must use AssertURLSafe.
+var AssertURLSchemeSafe = func(rawURL string) error {
+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil {
+ return fmt.Errorf("invalid url")
+ }
+
+ scheme := strings.ToLower(parsed.Scheme)
+ if !slices.Contains(AllowedURLSchemes, scheme) {
+ sorted := append([]string(nil), AllowedURLSchemes...)
+ sort.Strings(sorted)
+ return fmt.Errorf("disallowed URL scheme: '%s'. Only %v are allowed", scheme, sorted)
+ }
+
+ if parsed.Hostname() == "" {
+ return fmt.Errorf("URL is missing a host")
+ }
+
+ return nil
+}
+
// PinnedHTTPClient returns an HTTP client whose Transport rewrites every
// outbound dial for hostname:port to resolvedIP:port, closing the TOCTOU
// window between AssertURLSafe and the actual TCP connection. Pins are