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.
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/CHANGELOG.md +10 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/PKG-INFO +1 -1
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/pyproject.toml +1 -1
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/__init__.py +1 -1
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/anthropic.py +30 -7
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/factory.py +27 -3
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/openai_compatible.py +73 -17
- chatflow_agent-0.3.4/tests/test_engine_credentials.py +384 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/.github/workflows/publish.yml +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/.github/workflows/test.yml +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/.gitignore +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/LICENSE +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/README.md +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/cli_multiagent.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/local_gemma_cli.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/multi_provider_swarm.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/telegram_persistent_bot.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/telegram_triage.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/webhook_api_service.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/whatsapp_persistent_bot.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/examples/whatsapp_service.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/__init__.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/base.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/cli.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/telegram.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/channels/whatsapp.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/__init__.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/agent.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engine.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/__init__.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/base.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/gemini.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/memory.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/runner.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/tools.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/exceptions.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/memory.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/py.typed +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/src/chatflow_agent/types.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/__init__.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_agent.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_cli_channel.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_memory.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_memory_integrity.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_memory_sqlite.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_multi_engine.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_resilience.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_runner.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_runner_rollback.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_runner_security.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_telegram.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_tools.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_types.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp_background.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp_resilience_outbound.py +0 -0
- {chatflow_agent-0.3.3 → chatflow_agent-0.3.4}/tests/test_whatsapp_security.py +0 -0
- {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
|
+
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.3 → chatflow_agent-0.3.4}/src/chatflow_agent/core/engines/openai_compatible.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|