chatflow-agent 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/.github/workflows/publish.yml +0 -3
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/CHANGELOG.md +20 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/PKG-INFO +1 -1
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/pyproject.toml +1 -1
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/__init__.py +1 -1
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/anthropic.py +30 -7
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/factory.py +27 -3
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/openai_compatible.py +96 -19
- chatflow_agent-0.3.4/tests/test_engine_credentials.py +384 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_multi_engine.py +96 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/.github/workflows/test.yml +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/.gitignore +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/LICENSE +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/README.md +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/cli_multiagent.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/local_gemma_cli.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/multi_provider_swarm.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/telegram_persistent_bot.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/telegram_triage.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/webhook_api_service.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/whatsapp_persistent_bot.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/whatsapp_service.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/base.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/cli.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/telegram.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/whatsapp.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/agent.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engine.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/base.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/gemini.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/memory.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/runner.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/tools.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/exceptions.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/memory.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/py.typed +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/types.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_agent.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_cli_channel.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_memory.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_memory_integrity.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_memory_sqlite.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_resilience.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_runner.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_runner_rollback.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_runner_security.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_telegram.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_tools.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_types.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_background.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_resilience_outbound.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_security.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_split.py +0 -0
|
@@ -14,6 +14,16 @@ Versioning / Versionado: [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.3.4] - 2026-10-23
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- **Stricter API key scoping for custom endpoints:** API keys read from environment variables are now sent only to the official endpoint of the provider they belong to. Configurations that use a custom `base_url` (proxies, gateways, other providers) must pass `api_key` explicitly; `api_key=""` sends no credential. `OpenAIEngine` and `AnthropicEngine` no longer follow HTTP redirects, even with an injected client configured to. See GHSA-mcgm-q29p-3984.
|
|
21
|
+
|
|
22
|
+
## [0.3.3] - 2026-10-19
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
- **Strict Local Endpoint Hostname Validation:** `OpenAIEngine` now validates `base_url` structure, HTTP/HTTPS scheme, port range, and parsed hostname via `urllib.parse.urlsplit` when identifying local endpoints (`localhost`, `127.0.0.1`, `::1`, and local bind compatibility address `0.0.0.0`), replacing substring matching (Issue #74).
|
|
26
|
+
|
|
17
27
|
## [0.3.2] - 2026-09-29
|
|
18
28
|
|
|
19
29
|
### Security
|
|
@@ -69,6 +79,16 @@ If you are upgrading from v0.2.0:
|
|
|
69
79
|
|
|
70
80
|
## [Unreleased]
|
|
71
81
|
|
|
82
|
+
## [0.3.4] - 2026-10-23
|
|
83
|
+
|
|
84
|
+
### Seguridad
|
|
85
|
+
- **Alcance más estricto de las API keys en endpoints personalizados:** las API keys leídas de variables de entorno ahora se envían solo al endpoint oficial del proveedor al que pertenecen. Las configuraciones con un `base_url` personalizado (proxies, gateways, otros proveedores) deben pasar `api_key` explícitamente; `api_key=""` no envía ninguna credencial. `OpenAIEngine` y `AnthropicEngine` ya no siguen redirects HTTP, aunque el cliente inyectado esté configurado para hacerlo. Ver GHSA-mcgm-q29p-3984.
|
|
86
|
+
|
|
87
|
+
## [0.3.3] - 2026-10-19
|
|
88
|
+
|
|
89
|
+
### Seguridad
|
|
90
|
+
- **Validación Estricta de Hostname en Endpoints Locales:** `OpenAIEngine` ahora valida la estructura de `base_url`, el esquema HTTP/HTTPS, el rango de puerto y el hostname real mediante `urllib.parse.urlsplit` al identificar endpoints locales (`localhost`, `127.0.0.1`, `::1` y la dirección de compatibilidad local `0.0.0.0`), reemplazando la coincidencia por subcadenas (Issue #74).
|
|
91
|
+
|
|
72
92
|
## [0.3.2] - 2026-09-29
|
|
73
93
|
|
|
74
94
|
### Seguridad
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chatflow-agent
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Lightweight, async multi-agent framework with native handoffs for Gemini, Gemma, Grok, OpenAI & Claude.
|
|
5
5
|
Project-URL: Homepage, https://github.com/GustavoBaranda/chatflow-agent
|
|
6
6
|
Project-URL: Source Code, https://github.com/GustavoBaranda/chatflow-agent
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "chatflow-agent"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Lightweight, async multi-agent framework with native handoffs for Gemini, Gemma, Grok, OpenAI & Claude."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
-
import os
|
|
7
6
|
import random
|
|
8
7
|
from typing import TYPE_CHECKING, Any
|
|
9
8
|
|
|
10
9
|
import httpx
|
|
11
10
|
|
|
12
11
|
from chatflow_agent.core.engines.base import BaseEngine, EngineTurnResult
|
|
13
|
-
from chatflow_agent.core.engines.openai_compatible import
|
|
12
|
+
from chatflow_agent.core.engines.openai_compatible import (
|
|
13
|
+
_lowercase_schema_types,
|
|
14
|
+
_resolve_api_key,
|
|
15
|
+
)
|
|
14
16
|
from chatflow_agent.exceptions import ProviderError
|
|
15
17
|
from chatflow_agent.types import Message, Role, ToolCall
|
|
16
18
|
|
|
@@ -19,6 +21,8 @@ logger = logging.getLogger("chatflow_agent.engine.anthropic")
|
|
|
19
21
|
if TYPE_CHECKING:
|
|
20
22
|
from chatflow_agent.core.agent import Agent
|
|
21
23
|
|
|
24
|
+
ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"
|
|
25
|
+
|
|
22
26
|
|
|
23
27
|
class AnthropicEngine(BaseEngine):
|
|
24
28
|
"""Coordinates API requests with Anthropic Claude using Messages API over httpx."""
|
|
@@ -26,13 +30,20 @@ class AnthropicEngine(BaseEngine):
|
|
|
26
30
|
def __init__(
|
|
27
31
|
self,
|
|
28
32
|
api_key: str | None = None,
|
|
29
|
-
base_url: str =
|
|
33
|
+
base_url: str = ANTHROPIC_BASE_URL,
|
|
30
34
|
anthropic_version: str = "2023-06-01",
|
|
31
35
|
timeout: float = 60.0,
|
|
32
36
|
http_client: httpx.AsyncClient | None = None,
|
|
33
37
|
) -> None:
|
|
34
|
-
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
35
38
|
self.base_url = base_url.rstrip("/")
|
|
39
|
+
# ANTHROPIC_API_KEY goes only to the official endpoint; proxies, gateways
|
|
40
|
+
# and local servers need an explicit api_key.
|
|
41
|
+
self.api_key = _resolve_api_key(
|
|
42
|
+
api_key,
|
|
43
|
+
self.base_url,
|
|
44
|
+
official_base_url=ANTHROPIC_BASE_URL,
|
|
45
|
+
env_key="ANTHROPIC_API_KEY",
|
|
46
|
+
)
|
|
36
47
|
self.anthropic_version = anthropic_version
|
|
37
48
|
self.timeout = timeout
|
|
38
49
|
self.max_retries = 3
|
|
@@ -88,7 +99,9 @@ class AnthropicEngine(BaseEngine):
|
|
|
88
99
|
history: list[Message],
|
|
89
100
|
) -> EngineTurnResult:
|
|
90
101
|
"""Call Anthropic Messages API asynchronously."""
|
|
91
|
-
|
|
102
|
+
# api_key="" is an explicit choice to send no credential (e.g. an
|
|
103
|
+
# unauthenticated gateway); only a missing key is an error.
|
|
104
|
+
if self.api_key is None:
|
|
92
105
|
raise ProviderError(
|
|
93
106
|
"Anthropic API key is not configured. Set ANTHROPIC_API_KEY environment variable."
|
|
94
107
|
)
|
|
@@ -117,10 +130,11 @@ class AnthropicEngine(BaseEngine):
|
|
|
117
130
|
payload["tools"] = anthropic_tools
|
|
118
131
|
|
|
119
132
|
headers = {
|
|
120
|
-
"x-api-key": self.api_key,
|
|
121
133
|
"anthropic-version": self.anthropic_version,
|
|
122
134
|
"content-type": "application/json",
|
|
123
135
|
}
|
|
136
|
+
if self.api_key:
|
|
137
|
+
headers["x-api-key"] = self.api_key
|
|
124
138
|
|
|
125
139
|
url = f"{self.base_url}/messages"
|
|
126
140
|
|
|
@@ -128,7 +142,16 @@ class AnthropicEngine(BaseEngine):
|
|
|
128
142
|
while True:
|
|
129
143
|
client = self._external_client or httpx.AsyncClient(timeout=self.timeout)
|
|
130
144
|
try:
|
|
131
|
-
|
|
145
|
+
# Never follow redirects, even on an injected client configured to:
|
|
146
|
+
# httpx keeps x-api-key on cross-origin redirects.
|
|
147
|
+
resp = await client.post(
|
|
148
|
+
url, json=payload, headers=headers, follow_redirects=False
|
|
149
|
+
)
|
|
150
|
+
if 300 <= resp.status_code < 400:
|
|
151
|
+
raise ProviderError(
|
|
152
|
+
f"Anthropic API request was redirected (HTTP {resp.status_code}). "
|
|
153
|
+
"Redirects are not followed; set base_url to the final endpoint."
|
|
154
|
+
)
|
|
132
155
|
if resp.status_code in (429, 500, 502, 503, 504, 529):
|
|
133
156
|
if retries < self.max_retries:
|
|
134
157
|
retry_after_str = resp.headers.get("Retry-After")
|
|
@@ -5,7 +5,11 @@ from typing import TYPE_CHECKING, cast
|
|
|
5
5
|
from chatflow_agent.core.engines.anthropic import AnthropicEngine
|
|
6
6
|
from chatflow_agent.core.engines.base import BaseEngine
|
|
7
7
|
from chatflow_agent.core.engines.gemini import GeminiEngine
|
|
8
|
-
from chatflow_agent.core.engines.openai_compatible import
|
|
8
|
+
from chatflow_agent.core.engines.openai_compatible import (
|
|
9
|
+
PROVIDER_PRESETS,
|
|
10
|
+
OpenAIEngine,
|
|
11
|
+
_is_official_endpoint,
|
|
12
|
+
)
|
|
9
13
|
|
|
10
14
|
if TYPE_CHECKING:
|
|
11
15
|
from chatflow_agent.core.agent import Agent
|
|
@@ -18,6 +22,20 @@ _GEMINI_PREFIXES = ("gemini-",)
|
|
|
18
22
|
_OLLAMA_PREFIXES = ("gemma", "llama", "mistral", "qwen", "phi")
|
|
19
23
|
|
|
20
24
|
|
|
25
|
+
def _provider_for_official_base_url(base_url: str) -> str:
|
|
26
|
+
"""Return the preset whose official endpoint is exactly base_url, else "openai".
|
|
27
|
+
|
|
28
|
+
The provider decides which environment key OpenAIEngine may read, so a
|
|
29
|
+
base_url only selects a provider when it is that provider's official endpoint.
|
|
30
|
+
Any other URL keeps "openai", whose engine then requires an explicit api_key
|
|
31
|
+
(or applies the local-endpoint rule).
|
|
32
|
+
"""
|
|
33
|
+
for name, preset in PROVIDER_PRESETS.items():
|
|
34
|
+
if _is_official_endpoint(base_url, preset["base_url"]):
|
|
35
|
+
return name
|
|
36
|
+
return "openai"
|
|
37
|
+
|
|
38
|
+
|
|
21
39
|
def resolve_engine(agent: "Agent", default_engine: BaseEngine | None = None) -> BaseEngine:
|
|
22
40
|
"""Automatically resolve the appropriate LLM engine for an agent.
|
|
23
41
|
|
|
@@ -47,9 +65,15 @@ def resolve_engine(agent: "Agent", default_engine: BaseEngine | None = None) ->
|
|
|
47
65
|
if provider in ("openai", "grok", "xai", "ollama", "gemma", "deepseek", "groq"):
|
|
48
66
|
return OpenAIEngine(api_key=api_key, base_url=base_url, provider=provider)
|
|
49
67
|
|
|
50
|
-
# 3. Agent has custom base_url or api_key without provider
|
|
68
|
+
# 3. Agent has custom base_url or api_key without provider. An official
|
|
69
|
+
# endpoint (e.g. DeepSeek's) selects its own provider and environment key;
|
|
70
|
+
# OPENAI_API_KEY is never assumed for another host.
|
|
51
71
|
if base_url:
|
|
52
|
-
return OpenAIEngine(
|
|
72
|
+
return OpenAIEngine(
|
|
73
|
+
api_key=api_key,
|
|
74
|
+
base_url=base_url,
|
|
75
|
+
provider=_provider_for_official_base_url(base_url),
|
|
76
|
+
)
|
|
53
77
|
|
|
54
78
|
# 4. If Runner provided a default_engine (e.g. MockEngine or pre-configured engine), respect it!
|
|
55
79
|
if default_engine is not None:
|
{chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/openai_compatible.py
RENAMED
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
import logging
|
|
11
11
|
import os
|
|
12
12
|
from typing import TYPE_CHECKING, Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
13
14
|
|
|
14
15
|
import httpx
|
|
15
16
|
|
|
@@ -22,6 +23,83 @@ logger = logging.getLogger("chatflow_agent.engine.openai")
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
23
24
|
from chatflow_agent.core.agent import Agent
|
|
24
25
|
|
|
26
|
+
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
27
|
+
# Explicit compatibility exception for local dev servers bound to 0.0.0.0 (not a loopback address)
|
|
28
|
+
_COMPAT_LOCAL_BIND_HOSTS: frozenset[str] = frozenset({"0.0.0.0"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_local_base_url(base_url: str) -> bool:
|
|
32
|
+
"""Return True only when base_url is a well-formed HTTP(S) URL targeting a local host."""
|
|
33
|
+
try:
|
|
34
|
+
parsed = urlsplit(base_url)
|
|
35
|
+
_ = parsed.port
|
|
36
|
+
except ValueError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
|
|
40
|
+
return False
|
|
41
|
+
if parsed.username is not None or parsed.password is not None:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
hostname = (parsed.hostname or "").lower().rstrip(".")
|
|
45
|
+
return hostname in _LOOPBACK_HOSTS or hostname in _COMPAT_LOCAL_BIND_HOSTS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_official_endpoint(base_url: str, official_base_url: str) -> bool:
|
|
49
|
+
"""Return True only when base_url is exactly the given official HTTPS endpoint.
|
|
50
|
+
|
|
51
|
+
Both URLs are parsed with httpx, the client that sends the request, and compared
|
|
52
|
+
by scheme, host, port and path. Look-alike hosts, userinfo, plain HTTP, a query,
|
|
53
|
+
a fragment or a different path never match.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
url = httpx.URL(base_url)
|
|
57
|
+
official = httpx.URL(official_base_url)
|
|
58
|
+
except (httpx.InvalidURL, TypeError, ValueError):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
if url.scheme != "https" or url.userinfo or url.query or url.fragment:
|
|
62
|
+
return False
|
|
63
|
+
return (
|
|
64
|
+
url.host == official.host
|
|
65
|
+
and (url.port or 443) == (official.port or 443)
|
|
66
|
+
and url.path.rstrip("/") == official.path.rstrip("/")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _redact_url(url: str) -> str:
|
|
71
|
+
"""Return scheme, host, port and path only, so error messages never echo userinfo or a query."""
|
|
72
|
+
try:
|
|
73
|
+
parsed = httpx.URL(url)
|
|
74
|
+
except (httpx.InvalidURL, TypeError, ValueError):
|
|
75
|
+
return "<invalid URL>"
|
|
76
|
+
return f"{parsed.scheme}://{parsed.netloc.decode('ascii', 'replace')}{parsed.path}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_api_key(
|
|
80
|
+
api_key: str | None,
|
|
81
|
+
base_url: str,
|
|
82
|
+
*,
|
|
83
|
+
official_base_url: str,
|
|
84
|
+
env_key: str,
|
|
85
|
+
) -> str | None:
|
|
86
|
+
"""Resolve the credential for a request to base_url.
|
|
87
|
+
|
|
88
|
+
An explicit api_key (including "") is always used as given. The environment
|
|
89
|
+
variable env_key is read only when base_url is the official endpoint of the
|
|
90
|
+
provider that issued that key. Any other endpoint requires an explicit api_key.
|
|
91
|
+
"""
|
|
92
|
+
if api_key is not None:
|
|
93
|
+
return api_key
|
|
94
|
+
if _is_official_endpoint(base_url, official_base_url):
|
|
95
|
+
return os.environ.get(env_key)
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"A custom or unofficial base_url was provided ({_redact_url(base_url)!r}) without an explicit api_key. "
|
|
98
|
+
f"{env_key} is only sent to its official endpoint ({official_base_url}), so it is not used here. "
|
|
99
|
+
"Please provide api_key explicitly (or pass api_key='' if no authentication is required)."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
25
103
|
# Preset configurations for common OpenAI-compatible providers
|
|
26
104
|
PROVIDER_PRESETS: dict[str, dict[str, str]] = {
|
|
27
105
|
"openai": {
|
|
@@ -96,34 +174,24 @@ class OpenAIEngine(BaseEngine):
|
|
|
96
174
|
or preset.get("base_url", "https://api.openai.com/v1")
|
|
97
175
|
).rstrip("/")
|
|
98
176
|
|
|
99
|
-
official_base_urls = {
|
|
100
|
-
p["base_url"].rstrip("/") for p in PROVIDER_PRESETS.values()
|
|
101
|
-
}
|
|
102
|
-
is_official = self.base_url in official_base_urls
|
|
103
|
-
is_local = any(
|
|
104
|
-
host in self.base_url for host in ("localhost", "127.0.0.1", "0.0.0.0")
|
|
105
|
-
)
|
|
106
|
-
|
|
107
177
|
env_key_name = preset.get("env_key", "OPENAI_API_KEY")
|
|
108
178
|
|
|
109
179
|
self.api_key: str | None
|
|
110
|
-
if api_key is
|
|
111
|
-
self.api_key = api_key
|
|
112
|
-
elif is_local:
|
|
180
|
+
if api_key is None and _is_local_base_url(self.base_url):
|
|
113
181
|
# For local services (Ollama, LM Studio, vLLM on localhost), do not leak global cloud API keys
|
|
114
182
|
self.api_key = (
|
|
115
183
|
os.environ.get(env_key_name)
|
|
116
184
|
if self.provider in ("ollama", "gemma")
|
|
117
185
|
else None
|
|
118
186
|
) or "ollama"
|
|
119
|
-
elif is_official:
|
|
120
|
-
self.api_key = os.environ.get(env_key_name)
|
|
121
187
|
else:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
188
|
+
# The env key of the declared provider goes only to that provider's
|
|
189
|
+
# official endpoint, never to another preset's URL or a custom one.
|
|
190
|
+
self.api_key = _resolve_api_key(
|
|
191
|
+
api_key,
|
|
192
|
+
self.base_url,
|
|
193
|
+
official_base_url=preset.get("base_url", "https://api.openai.com/v1"),
|
|
194
|
+
env_key=env_key_name,
|
|
127
195
|
)
|
|
128
196
|
self.timeout = timeout
|
|
129
197
|
self.max_retries = 3
|
|
@@ -220,7 +288,16 @@ class OpenAIEngine(BaseEngine):
|
|
|
220
288
|
while True:
|
|
221
289
|
client = self._external_client or httpx.AsyncClient(timeout=self.timeout)
|
|
222
290
|
try:
|
|
223
|
-
|
|
291
|
+
# Never follow redirects, even on an injected client configured to:
|
|
292
|
+
# the credential must not be replayed to whatever host a 3xx names.
|
|
293
|
+
resp = await client.post(
|
|
294
|
+
url, json=payload, headers=headers, follow_redirects=False
|
|
295
|
+
)
|
|
296
|
+
if 300 <= resp.status_code < 400:
|
|
297
|
+
raise ProviderError(
|
|
298
|
+
f"{self.provider.upper()} API request was redirected (HTTP {resp.status_code}). "
|
|
299
|
+
"Redirects are not followed; set base_url to the final endpoint."
|
|
300
|
+
)
|
|
224
301
|
if resp.status_code in (429, 500, 502, 503, 504):
|
|
225
302
|
if retries < self.max_retries:
|
|
226
303
|
retry_after_str = resp.headers.get("Retry-After")
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Credential scoping for OpenAIEngine and AnthropicEngine.
|
|
2
|
+
|
|
3
|
+
An API key read from the environment may only be sent to the official endpoint of
|
|
4
|
+
the provider that issued it. All keys below are fictitious and every request is
|
|
5
|
+
served by ``httpx.MockTransport``; nothing leaves the process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from chatflow_agent.core.agent import Agent
|
|
16
|
+
from chatflow_agent.core.engines import (
|
|
17
|
+
AnthropicEngine,
|
|
18
|
+
OpenAIEngine,
|
|
19
|
+
resolve_engine,
|
|
20
|
+
)
|
|
21
|
+
from chatflow_agent.core.engines.base import BaseEngine
|
|
22
|
+
from chatflow_agent.exceptions import ProviderError
|
|
23
|
+
from chatflow_agent.types import Message, Role
|
|
24
|
+
|
|
25
|
+
FAKE_KEYS = {
|
|
26
|
+
"OPENAI_API_KEY": "sk-FAKE-openai-0000",
|
|
27
|
+
"XAI_API_KEY": "sk-FAKE-xai-0000",
|
|
28
|
+
"DEEPSEEK_API_KEY": "sk-FAKE-deepseek-0000",
|
|
29
|
+
"GROQ_API_KEY": "sk-FAKE-groq-0000",
|
|
30
|
+
"ANTHROPIC_API_KEY": "sk-FAKE-anthropic-0000",
|
|
31
|
+
"OLLAMA_API_KEY": "sk-FAKE-ollama-0000",
|
|
32
|
+
}
|
|
33
|
+
EXPLICIT_KEY = "sk-FAKE-explicit-proxy-0000"
|
|
34
|
+
PROXY_URL = "https://proxy.example.com/v1"
|
|
35
|
+
|
|
36
|
+
# Both engines accept this body: OpenAI reads "choices", Anthropic reads "content".
|
|
37
|
+
_OK_BODY: dict[str, list[object]] = {"choices": [], "content": []}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture(autouse=True)
|
|
41
|
+
def fake_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
42
|
+
"""Replace every real *_API_KEY / *_BASE_URL with fictitious keys."""
|
|
43
|
+
for name in list(os.environ):
|
|
44
|
+
if name.endswith(("_API_KEY", "_BASE_URL")):
|
|
45
|
+
monkeypatch.delenv(name)
|
|
46
|
+
for name, value in FAKE_KEYS.items():
|
|
47
|
+
monkeypatch.setenv(name, value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _agent(**kwargs: str) -> Agent:
|
|
51
|
+
kwargs.setdefault("model", "gpt-4o-mini")
|
|
52
|
+
return Agent(name="Credentials", instructions="Reply.", **kwargs) # type: ignore[arg-type]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _install(
|
|
56
|
+
engine: BaseEngine,
|
|
57
|
+
handler: Callable[[httpx.Request], httpx.Response],
|
|
58
|
+
*,
|
|
59
|
+
follow_redirects: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
engine._external_client = httpx.AsyncClient( # type: ignore[attr-defined]
|
|
62
|
+
transport=httpx.MockTransport(handler), follow_redirects=follow_redirects
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _send_one(engine: BaseEngine, agent: Agent) -> httpx.Request:
|
|
67
|
+
seen: list[httpx.Request] = []
|
|
68
|
+
|
|
69
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
70
|
+
seen.append(request)
|
|
71
|
+
return httpx.Response(200, json=_OK_BODY)
|
|
72
|
+
|
|
73
|
+
_install(engine, handler)
|
|
74
|
+
await engine.generate_turn_async(agent, [Message(role=Role.USER, content="hi")])
|
|
75
|
+
assert len(seen) == 1
|
|
76
|
+
return seen[0]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _credential(request: httpx.Request) -> str | None:
|
|
80
|
+
if "x-api-key" in request.headers:
|
|
81
|
+
return request.headers["x-api-key"]
|
|
82
|
+
auth = request.headers.get("authorization")
|
|
83
|
+
return auth.removeprefix("Bearer ") if auth else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- Configurations that send a key: it must be the right key for the right host.
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize(
|
|
89
|
+
("agent_kwargs", "expected_engine", "expected_host", "expected_key"),
|
|
90
|
+
[
|
|
91
|
+
({"provider": "openai"}, OpenAIEngine, "api.openai.com", FAKE_KEYS["OPENAI_API_KEY"]),
|
|
92
|
+
({"provider": "deepseek"}, OpenAIEngine, "api.deepseek.com", FAKE_KEYS["DEEPSEEK_API_KEY"]),
|
|
93
|
+
(
|
|
94
|
+
{"model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1"},
|
|
95
|
+
OpenAIEngine, "api.deepseek.com", FAKE_KEYS["DEEPSEEK_API_KEY"],
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
{"model": "grok-2", "base_url": "https://api.x.ai/v1"},
|
|
99
|
+
OpenAIEngine, "api.x.ai", FAKE_KEYS["XAI_API_KEY"],
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
{"model": "llama-3.3-70b-versatile", "base_url": "https://api.groq.com/openai/v1/"},
|
|
103
|
+
OpenAIEngine, "api.groq.com", FAKE_KEYS["GROQ_API_KEY"],
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
{"model": "gpt-4o", "base_url": "https://API.OPENAI.COM:443/v1/"},
|
|
107
|
+
OpenAIEngine, "api.openai.com", FAKE_KEYS["OPENAI_API_KEY"],
|
|
108
|
+
),
|
|
109
|
+
({"model": "claude-3-5-sonnet"}, AnthropicEngine, "api.anthropic.com", FAKE_KEYS["ANTHROPIC_API_KEY"]),
|
|
110
|
+
(
|
|
111
|
+
{"model": "claude-3-5-sonnet", "provider": "anthropic", "base_url": "https://api.anthropic.com/v1/"},
|
|
112
|
+
AnthropicEngine, "api.anthropic.com", FAKE_KEYS["ANTHROPIC_API_KEY"],
|
|
113
|
+
),
|
|
114
|
+
(
|
|
115
|
+
{"model": "gemma2:9b", "provider": "ollama", "base_url": "http://localhost:11434/v1"},
|
|
116
|
+
OpenAIEngine, "localhost", FAKE_KEYS["OLLAMA_API_KEY"],
|
|
117
|
+
),
|
|
118
|
+
# Explicit keys keep working with any endpoint (legitimate proxies and gateways).
|
|
119
|
+
({"base_url": PROXY_URL, "api_key": EXPLICIT_KEY}, OpenAIEngine, "proxy.example.com", EXPLICIT_KEY),
|
|
120
|
+
(
|
|
121
|
+
{"provider": "openai", "base_url": "https://api.groq.com/openai/v1", "api_key": EXPLICIT_KEY},
|
|
122
|
+
OpenAIEngine, "api.groq.com", EXPLICIT_KEY,
|
|
123
|
+
),
|
|
124
|
+
(
|
|
125
|
+
{"model": "claude-3-5-sonnet", "provider": "anthropic", "base_url": PROXY_URL, "api_key": EXPLICIT_KEY},
|
|
126
|
+
AnthropicEngine, "proxy.example.com", EXPLICIT_KEY,
|
|
127
|
+
),
|
|
128
|
+
],
|
|
129
|
+
)
|
|
130
|
+
async def test_environment_key_reaches_only_its_own_provider(
|
|
131
|
+
agent_kwargs: dict[str, str],
|
|
132
|
+
expected_engine: type[BaseEngine],
|
|
133
|
+
expected_host: str,
|
|
134
|
+
expected_key: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
agent = _agent(**agent_kwargs)
|
|
137
|
+
engine = resolve_engine(agent)
|
|
138
|
+
assert isinstance(engine, expected_engine)
|
|
139
|
+
|
|
140
|
+
request = await _send_one(engine, agent)
|
|
141
|
+
|
|
142
|
+
assert request.url.host == expected_host
|
|
143
|
+
assert _credential(request) == expected_key
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- Configurations that would send a key to a host that did not issue it.
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize(
|
|
149
|
+
"agent_kwargs",
|
|
150
|
+
[
|
|
151
|
+
{"provider": "openai", "base_url": "https://api.groq.com/openai/v1"},
|
|
152
|
+
{"provider": "grok", "base_url": "https://api.openai.com/v1"},
|
|
153
|
+
{"provider": "deepseek", "base_url": "https://api.x.ai/v1"},
|
|
154
|
+
{"provider": "openai", "base_url": PROXY_URL},
|
|
155
|
+
{"provider": "anthropic", "base_url": PROXY_URL},
|
|
156
|
+
{"provider": "claude", "base_url": "http://10.0.0.5:8080"},
|
|
157
|
+
{"provider": "anthropic", "base_url": "http://localhost:4000/v1"},
|
|
158
|
+
{"provider": "anthropic", "base_url": "https://api.openai.com/v1"},
|
|
159
|
+
{"model": "claude-3-5-sonnet", "base_url": PROXY_URL},
|
|
160
|
+
{"model": "gpt-4o", "base_url": "https://api.anthropic.com/v1"},
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
def test_environment_key_is_not_sent_to_other_endpoints(agent_kwargs: dict[str, str]) -> None:
|
|
164
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided") as exc_info:
|
|
165
|
+
resolve_engine(_agent(**agent_kwargs))
|
|
166
|
+
_assert_no_fake_key(str(exc_info.value))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize(
|
|
170
|
+
("official", "build"),
|
|
171
|
+
[
|
|
172
|
+
("https://api.openai.com/v1", lambda url: OpenAIEngine(provider="openai", base_url=url)),
|
|
173
|
+
("https://api.x.ai/v1", lambda url: OpenAIEngine(provider="grok", base_url=url)),
|
|
174
|
+
("https://api.anthropic.com/v1", lambda url: AnthropicEngine(base_url=url)),
|
|
175
|
+
],
|
|
176
|
+
)
|
|
177
|
+
@pytest.mark.parametrize(
|
|
178
|
+
"mutate",
|
|
179
|
+
[
|
|
180
|
+
lambda u: u.replace("https://", "http://"),
|
|
181
|
+
lambda u: u.replace("/v1", ".evil.example/v1"),
|
|
182
|
+
lambda u: u.replace("https://", "https://user:hunter2@"),
|
|
183
|
+
lambda u: u.replace("/v1", "@evil.example/v1"),
|
|
184
|
+
lambda u: u.replace("/v1", ":8443/v1"),
|
|
185
|
+
lambda u: u + "/extra",
|
|
186
|
+
lambda u: u.replace("/v1", "/v2"),
|
|
187
|
+
lambda u: u + "?redirect=evil.example",
|
|
188
|
+
lambda u: u + "#fragment",
|
|
189
|
+
lambda u: "https://evil.example/" + u,
|
|
190
|
+
],
|
|
191
|
+
)
|
|
192
|
+
def test_lookalike_official_urls_never_get_environment_key(
|
|
193
|
+
official: str,
|
|
194
|
+
build: Callable[[str], BaseEngine],
|
|
195
|
+
mutate: Callable[[str], str],
|
|
196
|
+
) -> None:
|
|
197
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided") as exc_info:
|
|
198
|
+
build(mutate(official))
|
|
199
|
+
message = str(exc_info.value)
|
|
200
|
+
_assert_no_fake_key(message)
|
|
201
|
+
assert "hunter2" not in message
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_resolve_engine_does_not_infer_provider_from_lookalike_host() -> None:
|
|
205
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
206
|
+
resolve_engine(_agent(model="deepseek-chat", base_url="https://api.deepseek.com.evil.example/v1"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- *_BASE_URL environment variables follow the same rule.
|
|
210
|
+
|
|
211
|
+
@pytest.mark.parametrize(
|
|
212
|
+
("env_var", "url", "provider"),
|
|
213
|
+
[
|
|
214
|
+
("OPENAI_BASE_URL", "https://api.x.ai/v1", "openai"),
|
|
215
|
+
("OPENAI_BASE_URL", PROXY_URL, "openai"),
|
|
216
|
+
("GROK_BASE_URL", "https://api.openai.com/v1", "grok"),
|
|
217
|
+
("XAI_BASE_URL", "https://api.openai.com/v1", "xai"),
|
|
218
|
+
("DEEPSEEK_BASE_URL", "https://api.groq.com/openai/v1", "deepseek"),
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
def test_base_url_env_var_does_not_unlock_another_providers_key(
|
|
222
|
+
monkeypatch: pytest.MonkeyPatch, env_var: str, url: str, provider: str
|
|
223
|
+
) -> None:
|
|
224
|
+
monkeypatch.setenv(env_var, url)
|
|
225
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
226
|
+
OpenAIEngine(provider=provider)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def test_base_url_env_var_pointing_to_own_official_endpoint_is_accepted(
|
|
230
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
231
|
+
) -> None:
|
|
232
|
+
monkeypatch.setenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1/")
|
|
233
|
+
engine = OpenAIEngine(provider="deepseek")
|
|
234
|
+
request = await _send_one(engine, _agent(model="deepseek-chat"))
|
|
235
|
+
assert request.url.host == "api.deepseek.com"
|
|
236
|
+
assert _credential(request) == FAKE_KEYS["DEEPSEEK_API_KEY"]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- Explicit empty key: no credential at all, even with keys in the environment.
|
|
240
|
+
|
|
241
|
+
@pytest.mark.parametrize(
|
|
242
|
+
"build",
|
|
243
|
+
[
|
|
244
|
+
lambda: OpenAIEngine(base_url=PROXY_URL, api_key=""),
|
|
245
|
+
lambda: AnthropicEngine(base_url=PROXY_URL, api_key=""),
|
|
246
|
+
lambda: AnthropicEngine(api_key=""),
|
|
247
|
+
lambda: resolve_engine(_agent(base_url=PROXY_URL, api_key="")),
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
async def test_explicit_empty_api_key_sends_no_credential(build: Callable[[], BaseEngine]) -> None:
|
|
251
|
+
engine = build()
|
|
252
|
+
request = await _send_one(engine, _agent(model="claude-3-5-sonnet"))
|
|
253
|
+
assert "authorization" not in request.headers
|
|
254
|
+
assert "x-api-key" not in request.headers
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.parametrize("env_has_key", [True, False], ids=["env-key", "no-env-key"])
|
|
258
|
+
@pytest.mark.parametrize(
|
|
259
|
+
("build", "env_var", "header"),
|
|
260
|
+
[
|
|
261
|
+
(lambda key: OpenAIEngine(provider="openai", api_key=key), "OPENAI_API_KEY", "authorization"),
|
|
262
|
+
(lambda key: AnthropicEngine(api_key=key), "ANTHROPIC_API_KEY", "x-api-key"),
|
|
263
|
+
],
|
|
264
|
+
ids=["openai", "anthropic"],
|
|
265
|
+
)
|
|
266
|
+
async def test_none_reads_environment_but_empty_string_never_does(
|
|
267
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
268
|
+
build: Callable[[str | None], BaseEngine],
|
|
269
|
+
env_var: str,
|
|
270
|
+
header: str,
|
|
271
|
+
env_has_key: bool,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""On the official endpoint, None falls back to the env key; "" never does and sends no header."""
|
|
274
|
+
if not env_has_key:
|
|
275
|
+
monkeypatch.delenv(env_var)
|
|
276
|
+
|
|
277
|
+
request = await _send_one(build(""), _agent())
|
|
278
|
+
assert header not in request.headers
|
|
279
|
+
|
|
280
|
+
engine = build(None)
|
|
281
|
+
if env_has_key:
|
|
282
|
+
request = await _send_one(engine, _agent())
|
|
283
|
+
assert _credential(request) == FAKE_KEYS[env_var]
|
|
284
|
+
elif isinstance(engine, AnthropicEngine):
|
|
285
|
+
with pytest.raises(ProviderError, match="not configured"):
|
|
286
|
+
await engine.generate_turn_async(_agent(), [Message(role=Role.USER, content="hi")])
|
|
287
|
+
else:
|
|
288
|
+
request = await _send_one(engine, _agent())
|
|
289
|
+
assert header not in request.headers
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def test_anthropic_without_any_key_still_reports_missing_configuration(
|
|
293
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
294
|
+
) -> None:
|
|
295
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY")
|
|
296
|
+
engine = AnthropicEngine()
|
|
297
|
+
with pytest.raises(ProviderError, match="ANTHROPIC_API_KEY"):
|
|
298
|
+
await engine.generate_turn_async(_agent(), [Message(role=Role.USER, content="hi")])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# --- Redirects: never followed, so no credential is replayed to another origin.
|
|
302
|
+
|
|
303
|
+
@pytest.mark.parametrize(
|
|
304
|
+
"location",
|
|
305
|
+
["https://evil.example/collect", "/v1/moved", "https://{host}/v1/moved"],
|
|
306
|
+
ids=["cross-origin", "same-origin-relative", "same-origin-absolute"],
|
|
307
|
+
)
|
|
308
|
+
@pytest.mark.parametrize("status", [301, 302, 303, 307, 308])
|
|
309
|
+
@pytest.mark.parametrize(
|
|
310
|
+
"build",
|
|
311
|
+
[
|
|
312
|
+
lambda: OpenAIEngine(provider="openai"),
|
|
313
|
+
lambda: AnthropicEngine(),
|
|
314
|
+
lambda: OpenAIEngine(base_url=PROXY_URL, api_key=EXPLICIT_KEY),
|
|
315
|
+
lambda: AnthropicEngine(base_url=PROXY_URL, api_key=EXPLICIT_KEY),
|
|
316
|
+
],
|
|
317
|
+
)
|
|
318
|
+
async def test_redirects_are_not_followed_even_by_injected_clients(
|
|
319
|
+
build: Callable[[], BaseEngine], status: int, location: str
|
|
320
|
+
) -> None:
|
|
321
|
+
engine = build()
|
|
322
|
+
seen: list[httpx.Request] = []
|
|
323
|
+
|
|
324
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
325
|
+
seen.append(request)
|
|
326
|
+
if request.url.host == "evil.example" or request.url.path.endswith("/moved"):
|
|
327
|
+
return httpx.Response(200, json=_OK_BODY)
|
|
328
|
+
return httpx.Response(
|
|
329
|
+
status, headers={"Location": location.format(host=request.url.host)}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
_install(engine, handler, follow_redirects=True)
|
|
333
|
+
|
|
334
|
+
with pytest.raises(ProviderError, match="redirected") as exc_info:
|
|
335
|
+
await engine.generate_turn_async(_agent(), [Message(role=Role.USER, content="hi")])
|
|
336
|
+
|
|
337
|
+
assert [r.url.host for r in seen] == [httpx.URL(engine.base_url).host] # type: ignore[attr-defined]
|
|
338
|
+
_assert_no_fake_key(str(exc_info.value))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# --- Errors and logs never contain a key.
|
|
342
|
+
|
|
343
|
+
def _assert_no_fake_key(text: str) -> None:
|
|
344
|
+
for secret in (*FAKE_KEYS.values(), EXPLICIT_KEY):
|
|
345
|
+
assert secret not in text
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@pytest.mark.parametrize(
|
|
349
|
+
"build",
|
|
350
|
+
[lambda: OpenAIEngine(provider="openai"), lambda: AnthropicEngine()],
|
|
351
|
+
)
|
|
352
|
+
@pytest.mark.parametrize(
|
|
353
|
+
"respond",
|
|
354
|
+
[
|
|
355
|
+
lambda request: httpx.Response(401, json={"error": "invalid key"}),
|
|
356
|
+
lambda request: httpx.Response(503, json={"error": "unavailable"}),
|
|
357
|
+
lambda request: httpx.Response(307, headers={"Location": "https://evil.example/"}),
|
|
358
|
+
lambda request: (_ for _ in ()).throw(httpx.ConnectError("refused", request=request)),
|
|
359
|
+
lambda request: (_ for _ in ()).throw(httpx.ReadTimeout("timed out", request=request)),
|
|
360
|
+
],
|
|
361
|
+
)
|
|
362
|
+
async def test_errors_and_logs_never_contain_api_keys(
|
|
363
|
+
caplog: pytest.LogCaptureFixture,
|
|
364
|
+
build: Callable[[], BaseEngine],
|
|
365
|
+
respond: Callable[[httpx.Request], httpx.Response],
|
|
366
|
+
) -> None:
|
|
367
|
+
engine = build()
|
|
368
|
+
engine.base_delay = 0.0 # type: ignore[attr-defined]
|
|
369
|
+
engine.max_delay = 0.0 # type: ignore[attr-defined]
|
|
370
|
+
_install(engine, respond)
|
|
371
|
+
|
|
372
|
+
with caplog.at_level(logging.DEBUG), pytest.raises(ProviderError) as exc_info:
|
|
373
|
+
await engine.generate_turn_async(_agent(), [Message(role=Role.USER, content="hi")])
|
|
374
|
+
|
|
375
|
+
chain: list[BaseException] = []
|
|
376
|
+
err: BaseException | None = exc_info.value
|
|
377
|
+
while err is not None:
|
|
378
|
+
chain.append(err)
|
|
379
|
+
err = err.__cause__ or err.__context__
|
|
380
|
+
_assert_no_fake_key(caplog.text)
|
|
381
|
+
for item in chain:
|
|
382
|
+
_assert_no_fake_key(str(item))
|
|
383
|
+
_assert_no_fake_key(repr(item))
|
|
384
|
+
_assert_no_fake_key(repr(engine))
|
|
@@ -279,6 +279,102 @@ def test_custom_base_url_rejects_fallback_to_global_api_key(monkeypatch: pytest.
|
|
|
279
279
|
assert engine_official.api_key == "sk-secret-prod-key-12345"
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
@pytest.mark.parametrize(
|
|
283
|
+
"spoofed_or_malformed_url",
|
|
284
|
+
[
|
|
285
|
+
"https://localhost.untrusted.example.com/v1",
|
|
286
|
+
"https://sub.localhost.example.com/v1",
|
|
287
|
+
"https://my-localhost.example.com/v1",
|
|
288
|
+
"https://untrusted.example.com/localhost/v1",
|
|
289
|
+
"https://untrusted.example.com/v1?origin=localhost",
|
|
290
|
+
"https://localhost@untrusted.example.com/v1",
|
|
291
|
+
"http://user:pass@localhost:11434/v1",
|
|
292
|
+
"https://127.0.0.1.untrusted.example.com/v1",
|
|
293
|
+
"https://untrusted.example.com/127.0.0.1/v1",
|
|
294
|
+
"https://0.0.0.0.untrusted.example.com/v1",
|
|
295
|
+
"ftp://localhost:11434/v1",
|
|
296
|
+
"localhost:11434/v1",
|
|
297
|
+
"http:///localhost/v1",
|
|
298
|
+
"http://[::1:11434/v1",
|
|
299
|
+
"http://localhost:badport/v1",
|
|
300
|
+
"http://localhost:99999/v1",
|
|
301
|
+
],
|
|
302
|
+
)
|
|
303
|
+
def test_spoofed_or_malformed_local_base_url_is_rejected(
|
|
304
|
+
monkeypatch: pytest.MonkeyPatch, spoofed_or_malformed_url: str
|
|
305
|
+
) -> None:
|
|
306
|
+
"""External or malformed URLs containing local substrings must not bypass SEC-07 or leak env keys."""
|
|
307
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret-prod-key")
|
|
308
|
+
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-secret-token")
|
|
309
|
+
|
|
310
|
+
for provider in ("openai", "ollama", "gemma"):
|
|
311
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
312
|
+
OpenAIEngine(provider=provider, base_url=spoofed_or_malformed_url)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@pytest.mark.parametrize(
|
|
316
|
+
"spoofed_url",
|
|
317
|
+
[
|
|
318
|
+
"https://localhost.untrusted.example.com/v1",
|
|
319
|
+
"https://untrusted.example.com/localhost/v1",
|
|
320
|
+
"https://127.0.0.1.untrusted.example.com/v1",
|
|
321
|
+
"https://0.0.0.0.untrusted.example.com/v1",
|
|
322
|
+
"https://localhost@untrusted.example.com/v1",
|
|
323
|
+
],
|
|
324
|
+
)
|
|
325
|
+
def test_spoofed_local_base_url_from_env_vars_is_rejected(
|
|
326
|
+
monkeypatch: pytest.MonkeyPatch, spoofed_url: str
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Spoofed URLs provided via OPENAI_BASE_URL or OLLAMA_BASE_URL without explicit base_url must be rejected."""
|
|
329
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret-prod-key")
|
|
330
|
+
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-secret-token")
|
|
331
|
+
|
|
332
|
+
monkeypatch.setenv("OPENAI_BASE_URL", spoofed_url)
|
|
333
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
334
|
+
OpenAIEngine(provider="openai")
|
|
335
|
+
|
|
336
|
+
monkeypatch.setenv("OLLAMA_BASE_URL", spoofed_url)
|
|
337
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
338
|
+
OpenAIEngine(provider="ollama")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@pytest.mark.parametrize(
|
|
342
|
+
"local_url",
|
|
343
|
+
[
|
|
344
|
+
"http://localhost:11434/v1",
|
|
345
|
+
"http://LOCALHOST:11434/v1/",
|
|
346
|
+
"http://localhost.:11434/v1",
|
|
347
|
+
"https://localhost:8443/v1",
|
|
348
|
+
"http://127.0.0.1:11434/v1",
|
|
349
|
+
"http://[::1]:11434/v1",
|
|
350
|
+
"http://0.0.0.0:8000/v1",
|
|
351
|
+
],
|
|
352
|
+
)
|
|
353
|
+
def test_valid_local_base_urls_are_accepted_and_protect_cloud_keys(
|
|
354
|
+
monkeypatch: pytest.MonkeyPatch, local_url: str
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Valid loopback (localhost, 127.0.0.1, ::1) and compatibility 0.0.0.0 URLs work without leaking cloud keys."""
|
|
357
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret-prod-key")
|
|
358
|
+
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
|
359
|
+
|
|
360
|
+
# provider='openai' pointed at local server must NOT leak OPENAI_API_KEY
|
|
361
|
+
engine_openai = OpenAIEngine(provider="openai", base_url=local_url)
|
|
362
|
+
assert engine_openai.api_key == "ollama"
|
|
363
|
+
|
|
364
|
+
# provider='ollama' / 'gemma' without OLLAMA_API_KEY defaults to 'ollama'
|
|
365
|
+
engine_ollama = OpenAIEngine(provider="ollama", base_url=local_url)
|
|
366
|
+
assert engine_ollama.api_key == "ollama"
|
|
367
|
+
engine_gemma = OpenAIEngine(provider="gemma", base_url=local_url)
|
|
368
|
+
assert engine_gemma.api_key == "ollama"
|
|
369
|
+
|
|
370
|
+
# provider='ollama' / 'gemma' with OLLAMA_API_KEY honors it on genuine local URLs
|
|
371
|
+
monkeypatch.setenv("OLLAMA_API_KEY", "local-ollama-token")
|
|
372
|
+
engine_ollama_auth = OpenAIEngine(provider="ollama", base_url=local_url)
|
|
373
|
+
assert engine_ollama_auth.api_key == "local-ollama-token"
|
|
374
|
+
engine_gemma_auth = OpenAIEngine(provider="gemma", base_url=local_url)
|
|
375
|
+
assert engine_gemma_auth.api_key == "local-ollama-token"
|
|
376
|
+
|
|
377
|
+
|
|
282
378
|
@pytest.mark.asyncio
|
|
283
379
|
async def test_anthropic_retry_on_529_overloaded() -> None:
|
|
284
380
|
"""Test AnthropicEngine retries on HTTP 529 Overloaded and succeeds."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|