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.
Files changed (58) hide show
  1. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/.github/workflows/publish.yml +0 -3
  2. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/CHANGELOG.md +20 -0
  3. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/PKG-INFO +1 -1
  4. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/pyproject.toml +1 -1
  5. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/__init__.py +1 -1
  6. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/anthropic.py +30 -7
  7. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/factory.py +27 -3
  8. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/openai_compatible.py +96 -19
  9. chatflow_agent-0.3.4/tests/test_engine_credentials.py +384 -0
  10. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_multi_engine.py +96 -0
  11. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/.github/workflows/test.yml +0 -0
  12. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/.gitignore +0 -0
  13. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/LICENSE +0 -0
  14. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/README.md +0 -0
  15. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/cli_multiagent.py +0 -0
  16. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/local_gemma_cli.py +0 -0
  17. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/multi_provider_swarm.py +0 -0
  18. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/telegram_persistent_bot.py +0 -0
  19. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/telegram_triage.py +0 -0
  20. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/webhook_api_service.py +0 -0
  21. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/whatsapp_persistent_bot.py +0 -0
  22. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/examples/whatsapp_service.py +0 -0
  23. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/__init__.py +0 -0
  24. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/base.py +0 -0
  25. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/cli.py +0 -0
  26. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/telegram.py +0 -0
  27. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/whatsapp.py +0 -0
  28. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/__init__.py +0 -0
  29. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/agent.py +0 -0
  30. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engine.py +0 -0
  31. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/__init__.py +0 -0
  32. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/base.py +0 -0
  33. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/gemini.py +0 -0
  34. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/memory.py +0 -0
  35. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/runner.py +0 -0
  36. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/core/tools.py +0 -0
  37. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/exceptions.py +0 -0
  38. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/memory.py +0 -0
  39. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/py.typed +0 -0
  40. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/src/chatflow_agent/types.py +0 -0
  41. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/__init__.py +0 -0
  42. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_agent.py +0 -0
  43. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_cli_channel.py +0 -0
  44. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_memory.py +0 -0
  45. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_memory_integrity.py +0 -0
  46. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_memory_sqlite.py +0 -0
  47. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_resilience.py +0 -0
  48. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_runner.py +0 -0
  49. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_runner_rollback.py +0 -0
  50. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_runner_security.py +0 -0
  51. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_telegram.py +0 -0
  52. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_tools.py +0 -0
  53. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_types.py +0 -0
  54. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp.py +0 -0
  55. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_background.py +0 -0
  56. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_resilience_outbound.py +0 -0
  57. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_security.py +0 -0
  58. {chatflow_agent-0.3.2 → chatflow_agent-0.3.4}/tests/test_whatsapp_split.py +0 -0
@@ -3,9 +3,6 @@ name: Publish to PyPI
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
- push:
7
- tags:
8
- - "v*"
9
6
 
10
7
  jobs:
11
8
  build-and-publish:
@@ -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.2
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.2"
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.2"
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:
@@ -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 not None:
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
- raise ValueError(
123
- f"A custom or unofficial base_url was provided ({self.base_url!r}) without an explicit api_key. "
124
- f"To prevent accidental leakage of your global {env_key_name} to untrusted endpoints, "
125
- "fallback to environment API keys is disabled. "
126
- "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,
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
- 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
+ )
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