chatflow-agent 0.3.3__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.
Files changed (58) hide show
  1. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/CHANGELOG.md +10 -0
  2. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/PKG-INFO +1 -1
  3. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/pyproject.toml +1 -1
  4. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/__init__.py +1 -1
  5. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/anthropic.py +30 -7
  6. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/factory.py +27 -3
  7. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/openai_compatible.py +73 -17
  8. chatflow_agent-0.3.4/tests/test_engine_credentials.py +384 -0
  9. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/.github/workflows/publish.yml +0 -0
  10. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/.github/workflows/test.yml +0 -0
  11. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/.gitignore +0 -0
  12. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/LICENSE +0 -0
  13. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/README.md +0 -0
  14. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/cli_multiagent.py +0 -0
  15. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/local_gemma_cli.py +0 -0
  16. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/multi_provider_swarm.py +0 -0
  17. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/telegram_persistent_bot.py +0 -0
  18. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/telegram_triage.py +0 -0
  19. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/webhook_api_service.py +0 -0
  20. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/whatsapp_persistent_bot.py +0 -0
  21. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/whatsapp_service.py +0 -0
  22. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/__init__.py +0 -0
  23. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/base.py +0 -0
  24. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/cli.py +0 -0
  25. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/telegram.py +0 -0
  26. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/whatsapp.py +0 -0
  27. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/__init__.py +0 -0
  28. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/agent.py +0 -0
  29. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engine.py +0 -0
  30. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/__init__.py +0 -0
  31. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/base.py +0 -0
  32. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/gemini.py +0 -0
  33. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/memory.py +0 -0
  34. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/runner.py +0 -0
  35. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/tools.py +0 -0
  36. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/exceptions.py +0 -0
  37. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/memory.py +0 -0
  38. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/py.typed +0 -0
  39. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/types.py +0 -0
  40. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/__init__.py +0 -0
  41. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_agent.py +0 -0
  42. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_cli_channel.py +0 -0
  43. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_memory.py +0 -0
  44. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_memory_integrity.py +0 -0
  45. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_memory_sqlite.py +0 -0
  46. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_multi_engine.py +0 -0
  47. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_resilience.py +0 -0
  48. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_runner.py +0 -0
  49. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_runner_rollback.py +0 -0
  50. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_runner_security.py +0 -0
  51. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_telegram.py +0 -0
  52. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_tools.py +0 -0
  53. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_types.py +0 -0
  54. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp.py +0 -0
  55. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp_background.py +0 -0
  56. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp_resilience_outbound.py +0 -0
  57. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp_security.py +0 -0
  58. {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp_split.py +0 -0
@@ -14,6 +14,11 @@ 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
+
17
22
  ## [0.3.3] - 2026-10-19
18
23
 
19
24
  ### Security
@@ -74,6 +79,11 @@ If you are upgrading from v0.2.0:
74
79
 
75
80
  ## [Unreleased]
76
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
+
77
87
  ## [0.3.3] - 2026-10-19
78
88
 
79
89
  ### Seguridad
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chatflow-agent
3
- Version: 0.3.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.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"
@@ -34,7 +34,7 @@ from chatflow_agent.types import (
34
34
  # Alias for semantic consistency
35
35
  BaseSessionContext = SessionContext
36
36
 
37
- __version__ = "0.3.3"
37
+ __version__ = "0.3.4"
38
38
  __all__ = [
39
39
  "__version__",
40
40
  "Agent",
@@ -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 _lowercase_schema_types
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 = "https://api.anthropic.com/v1",
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
- if not self.api_key:
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
- resp = await client.post(url, json=payload, headers=headers)
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 OpenAIEngine
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(api_key=api_key, base_url=base_url, provider="openai")
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:
@@ -45,6 +45,61 @@ def _is_local_base_url(base_url: str) -> bool:
45
45
  return hostname in _LOOPBACK_HOSTS or hostname in _COMPAT_LOCAL_BIND_HOSTS
46
46
 
47
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
+
48
103
  # Preset configurations for common OpenAI-compatible providers
49
104
  PROVIDER_PRESETS: dict[str, dict[str, str]] = {
50
105
  "openai": {
@@ -119,32 +174,24 @@ class OpenAIEngine(BaseEngine):
119
174
  or preset.get("base_url", "https://api.openai.com/v1")
120
175
  ).rstrip("/")
121
176
 
122
- official_base_urls = {
123
- p["base_url"].rstrip("/") for p in PROVIDER_PRESETS.values()
124
- }
125
- is_official = self.base_url in official_base_urls
126
- is_local = _is_local_base_url(self.base_url)
127
-
128
177
  env_key_name = preset.get("env_key", "OPENAI_API_KEY")
129
178
 
130
179
  self.api_key: str | None
131
- if api_key is not None:
132
- self.api_key = api_key
133
- elif is_local:
180
+ if api_key is None and _is_local_base_url(self.base_url):
134
181
  # For local services (Ollama, LM Studio, vLLM on localhost), do not leak global cloud API keys
135
182
  self.api_key = (
136
183
  os.environ.get(env_key_name)
137
184
  if self.provider in ("ollama", "gemma")
138
185
  else None
139
186
  ) or "ollama"
140
- elif is_official:
141
- self.api_key = os.environ.get(env_key_name)
142
187
  else:
143
- raise ValueError(
144
- f"A custom or unofficial base_url was provided ({self.base_url!r}) without an explicit api_key. "
145
- f"To prevent accidental leakage of your global {env_key_name} to untrusted endpoints, "
146
- "fallback to environment API keys is disabled. "
147
- "Please provide api_key explicitly (or pass api_key='' if no authentication is required)."
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,
148
195
  )
149
196
  self.timeout = timeout
150
197
  self.max_retries = 3
@@ -241,7 +288,16 @@ class OpenAIEngine(BaseEngine):
241
288
  while True:
242
289
  client = self._external_client or httpx.AsyncClient(timeout=self.timeout)
243
290
  try:
244
- resp = await client.post(url, json=payload, headers=headers)
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
+ )
245
301
  if resp.status_code in (429, 500, 502, 503, 504):
246
302
  if retries < self.max_retries:
247
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))
File without changes
File without changes