π Bug Report β 13 Confirmed Bugs (Independent Review Pass)
Reviewed by: @wilsonhj
Severity summary: 2 Critical Β· 5 High Β· 4 Medium Β· 1 Security regression (round 2)
Affected version: 0.0.1 (current main, post-MCP merge)
Review method: 7-angle independent multi-agent code review (Round 1: 10 bugs), followed by an 8-angle review of Round 1's own diff (Round 2: 3 corrections/regressions caught), plus a final hardening pass (Round 3: 3 additional fixes). Total: 13 distinct root causes, all fixed.
Note: These are entirely independent of Issue #6 / PR #7. Some findings touch the same files (lcel/model.py, utils/session_map.py) but different functions and lines.
A fix PR addressing all 13 bugs, with 99 passing unit tests and no regressions, accompanies this issue.
Round 1 β 7-Angle Review (10 Bugs)
Bug 1 β AzureOpenAI Constructed with api_version Supplied Twice [CRITICAL]
File: connectchain/lcel/model.py (_get_azure_model_, Azure branch of _get_direct_model_)
Severity: π΄ Critical β Azure support non-functional against real client
_get_azure_model_() passes api_version both as a direct constructor kwarg and inside model_kwargs={"api_version": ...}. The real AzureOpenAI client raises TypeError: __init__() got multiple values for keyword argument 'api_version'. Every existing test mocks AzureOpenAI out, so this crash was invisible to the test suite. Reproduced directly against the real class.
Fix: Extract a shared _azure_model_kwargs_() helper that builds the dict once; pass api_version only as a top-level kwarg.
Bug 2 β hasattr()/getattr(default=...) Are No-ops Against ConfigWrapper [HIGH]
File: connectchain/lcel/model.py (Azure branch of _get_direct_model_)
Severity: π High β Silent wrong behavior (temperature forced to None)
ConfigWrapper.__getattr__ returns None for missing keys instead of raising AttributeError. This means hasattr(model_config, "api_version") always returns True, and getattr(model_config, "api_version", None) always returns None via __getattr__. The Azure engine/api_version fallback logic silently evaluates to None for every config that doesn't explicitly set those keys.
Fix (with Round 2 correction): Check model_config.api_version is not None explicitly. Add a guard that raises LCELModelException with a clear message when an Azure-shaped api_base is configured without api_version (so misconfiguration fails loudly, not silently).
Bug 3 β LCELRetry.invoke() Drops **kwargs That ainvoke() Forwards Correctly [HIGH]
File: connectchain/lcel/lcel_retry.py
Severity: π High β Sync/async inconsistency
LCELRetry.invoke() does not forward **kwargs to the underlying runnable. ainvoke() does. Identical calls with extra kwargs behave differently depending on whether they're sync or async.
Fix: Add **kwargs forwarding to invoke()'s call: self._runnable.invoke(input, config, **kwargs).
Bug 4 β ValidPromptTemplate Sanitizes Per-Field Inputs, Not the Rendered Prompt [CRITICAL]
File: connectchain/prompts/valid_prompt_template.py
Severity: π΄ Critical β Sanitizer bypass via field splitting
The class docstring says it sanitizes "the rendered prompt". The implementation sanitizes each raw template field value individually before rendering. Disallowed content split across two template fields (e.g., {"word1": "bad", "word2": "word"} with template "{word1} {word2}") bypasses the sanitizer β the check runs on "bad" and "word" separately, neither triggers the filter, and the final rendered string "bad word" is never checked.
# Reproduction
sanitizer = lambda s: s if "bad word" not in s else (_ for _ in ()).throw(ValueError("blocked"))
template = ValidPromptTemplate(template="{a} {b}", input_variables=["a", "b"], sanitizer=sanitizer)
template.format(a="bad", b="word") # No exception β sanitizer never sees "bad word"
Fix: Move the sanitizer call to run on the final rendered string (output of super().format(...)).
Bug 5 β _wrap_method_'s hasattr() Can Raise the ValueError It Was Written to Guard Against [MEDIUM]
File: connectchain/utils/llm_proxy_wrapper.py
Severity: π‘ Medium β Unexpected exception path
_wrap_method_ calls hasattr(llm, method_name) before the try block. On Pydantic v2 models (which LangChain 0.3.x uses), accessing a field that doesn't exist can trigger validators that raise ValueError. The surrounding try/except ValueError was written to catch exactly this β but hasattr runs before it.
Fix: Move the hasattr check inside the try block, or use getattr(llm, method_name, None) with a None guard.
Bug 6 β SessionMap.__new__ Silently Ignores expires_in After First Construction [HIGH]
File: connectchain/utils/session_map.py
Severity: π High β Wrong expiry policy applied per model
SessionMap is a singleton. After the first construction, every subsequent SessionMap(different_interval) call returns the existing instance without updating expires_in. If two models use different token refresh intervals, the second model's interval is silently ignored.
Fix (with Round 2 correction): Capture expires_in per-session at cache time (store it alongside the LLM in new_session()), rather than reading the singleton's current self.expires_in in is_expired(). Makes each session's expiry policy independent.
Bug 7 β Custom Exceptions Subclass BaseException Instead of Exception [HIGH + SECURITY]
File: connectchain/utils/exceptions.py, connectchain/lcel/model.py
Severity: π High (3 of 4 exceptions) + π Security note (1 of 4)
LCELModelException, ConfigException, and UtilException subclass BaseException instead of Exception. Standard except Exception: handlers will never catch them β they propagate uncaught through catch-all blocks, crashing the application.
ConnectChainNoAccessException is intentionally BaseException β it is the kill-switch that disables APIChain in connectchain/__init__.py. This one must remain BaseException so that ordinary application except Exception: code cannot swallow it.
Fix: Change LCELModelException, ConfigException, and UtilException to subclass Exception. Add an explanatory comment on ConnectChainNoAccessException documenting why it remains BaseException.
Bug 8 β MCPToolLoader.load_tools([]) Treats Explicit Empty List as "No Filter" [MEDIUM]
File: connectchain/tools/mcp/tool_loader.py
Severity: π‘ Medium β Semantic mismatch; [] and None have opposite meanings
The filter parameter is checked with if not server_names:. An explicit empty list [] ("load from no servers") and None ("load from all servers") both evaluate falsy β so load_tools([]) loads all tools instead of none.
Fix: Check if server_names is None: to distinguish "unset" from "explicitly empty".
Bug 9 β MCPToolAgent Silently Drops Tools on Same-Name Collisions Across Servers [MEDIUM]
File: connectchain/tools/mcp/mcp_tool_agent.py
Severity: π‘ Medium β Silent data loss, no warning
When two MCP servers expose tools with the same name, the second tool silently overwrites the first in the internal tools dict. No warning is logged; the dropped tool is unrecoverable.
Fix: Log a WARNING when a name collision is detected, including both server names and the tool name.
Bug 10 β MCPToolAgent.ainvoke Violates -> dict Return Type on No-Tool Path [MEDIUM]
File: connectchain/tools/mcp/mcp_tool_agent.py
Severity: π‘ Medium β Type contract violation
ainvoke is annotated -> dict but returns a plain str when the agent decides no tool call is needed. Callers that unpack the return value as a dict crash on the direct-answer path.
Fix: Wrap the direct-answer string: return {"output": response_text}. Updated the one existing test that relied on the old shape.
Round 2 β 8-Angle Review of Round 1's Diff (3 Corrections)
Correction A β Security Regression: ConnectChainNoAccessException Over-Broadened
Round 1 grouped all four exceptions together and changed them all to Exception. This broke the APIChain kill-switch in connectchain/__init__.py β ConnectChainNoAccessException is specifically designed to not be catchable by ordinary application code. Reverted to BaseException for this exception only, with a comment.
Correction B β SessionMap Per-Session Expiry Fix Was Incomplete
Round 1 updated expires_in on every construction β stopping a later config from being silently ignored β but expires_in was still one shared singleton field applied to all cached sessions. A later SessionMap(different_interval) for model B would retroactively change model A's effective expiry. Fixed by storing expires_in per-session at new_session() call time.
Correction C β Azure Missing api_version Now Fails Silently Instead of Loudly
The hasattrβgetattr fix for the Azure branch changed the gating condition from "always true" (guaranteed visible crash) to "false when api_version unset" β falling through to ChatOpenAI pointed at the Azure endpoint, sending non-Azure-shaped requests that fail confusingly at call time. Added an explicit guard that raises LCELModelException with a clear message.
Round 3 β Hardening Pass (3 Additional Fixes)
Bug 11 β SessionMap Crashes with None-Valued expires_in [HIGH]
File: connectchain/utils/session_map.py
If SessionMap is constructed with expires_in=None (from a config that doesn't set the field β ConfigWrapper returns None for missing keys), is_expired() crashes with TypeError: '>' not supported between instances of 'float' and 'NoneType'.
Fix: Validate expires_in at construction time; raise ValueError immediately if None or non-positive.
Bug 12 β Azure Detection Too Narrow: Sovereign Cloud Endpoints Not Recognized [HIGH]
File: connectchain/lcel/model.py
Azure detection checks "openai.azure.com" in str(api_base). Sovereign cloud endpoints (*.openai.azure.us, *.cognitive.microsoft.com) don't match, silently falling through to ChatOpenAI instead of AzureOpenAI.
Fix: Broaden the detection to cover known sovereign patterns; also support an explicit azure: true flag in model config as a reliable override.
Bug 13 β YAML Date Coercion Breaks api_version String [MEDIUM]
File: Config loading path (connectchain/utils/config.py)
YAML parses values like 2024-02-01 as datetime.date objects, not strings. Azure OpenAI API versions use this exact date format. They arrive as date objects and cause TypeError when passed to the Azure client.
Fix: Coerce api_version to str during config loading; document the quoting requirement in the example config.
Summary Table
| # |
File |
Severity |
One-Line Description |
| 1 |
lcel/model.py |
π΄ Critical |
api_version passed twice to AzureOpenAI β always crashes |
| 2 |
lcel/model.py |
π High |
hasattr/getattr no-ops against ConfigWrapper β silent None values |
| 3 |
lcel/lcel_retry.py |
π High |
invoke() drops **kwargs; ainvoke() doesn't β sync/async inconsistency |
| 4 |
prompts/valid_prompt_template.py |
π΄ Critical |
Sanitizer runs per-field, not rendered prompt β bypass via field splitting |
| 5 |
utils/llm_proxy_wrapper.py |
π‘ Medium |
hasattr before try-block can raise the ValueError the block catches |
| 6 |
utils/session_map.py |
π High |
Singleton ignores expires_in after first construction β wrong expiry per model |
| 7 |
utils/exceptions.py |
π High + π |
3 exceptions inherit BaseException; uncatchable by standard except Exception: |
| 8 |
tools/mcp/tool_loader.py |
π‘ Medium |
Empty list [] treated as None (no filter) instead of "no servers" |
| 9 |
tools/mcp/mcp_tool_agent.py |
π‘ Medium |
Tool name collisions across servers silently dropped with no warning |
| 10 |
tools/mcp/mcp_tool_agent.py |
π‘ Medium |
ainvoke returns str on no-tool path, violates -> dict annotation |
| 11 |
utils/session_map.py |
π High |
None-valued expires_in crashes is_expired() at runtime |
| 12 |
lcel/model.py |
π High |
Sovereign cloud Azure endpoints bypass detection heuristic |
| 13 |
Config loading |
π‘ Medium |
YAML parses api_version date strings as date objects β TypeError |
Fix PR with 99 passing unit tests, mypy-clean, and pylint score unchanged follows.
π Bug Report β 13 Confirmed Bugs (Independent Review Pass)
Reviewed by: @wilsonhj
Severity summary: 2 Critical Β· 5 High Β· 4 Medium Β· 1 Security regression (round 2)
Affected version:
0.0.1(currentmain, post-MCP merge)Review method: 7-angle independent multi-agent code review (Round 1: 10 bugs), followed by an 8-angle review of Round 1's own diff (Round 2: 3 corrections/regressions caught), plus a final hardening pass (Round 3: 3 additional fixes). Total: 13 distinct root causes, all fixed.
Note: These are entirely independent of Issue #6 / PR #7. Some findings touch the same files (
lcel/model.py,utils/session_map.py) but different functions and lines.A fix PR addressing all 13 bugs, with 99 passing unit tests and no regressions, accompanies this issue.
Round 1 β 7-Angle Review (10 Bugs)
Bug 1 β
AzureOpenAIConstructed withapi_versionSupplied Twice [CRITICAL]File:
connectchain/lcel/model.py(_get_azure_model_, Azure branch of_get_direct_model_)Severity: π΄ Critical β Azure support non-functional against real client
_get_azure_model_()passesapi_versionboth as a direct constructor kwarg and insidemodel_kwargs={"api_version": ...}. The realAzureOpenAIclient raisesTypeError: __init__() got multiple values for keyword argument 'api_version'. Every existing test mocksAzureOpenAIout, so this crash was invisible to the test suite. Reproduced directly against the real class.Fix: Extract a shared
_azure_model_kwargs_()helper that builds the dict once; passapi_versiononly as a top-level kwarg.Bug 2 β
hasattr()/getattr(default=...)Are No-ops AgainstConfigWrapper[HIGH]File:
connectchain/lcel/model.py(Azure branch of_get_direct_model_)Severity: π High β Silent wrong behavior (temperature forced to
None)ConfigWrapper.__getattr__returnsNonefor missing keys instead of raisingAttributeError. This meanshasattr(model_config, "api_version")always returnsTrue, andgetattr(model_config, "api_version", None)always returnsNonevia__getattr__. The Azureengine/api_versionfallback logic silently evaluates toNonefor every config that doesn't explicitly set those keys.Fix (with Round 2 correction): Check
model_config.api_version is not Noneexplicitly. Add a guard that raisesLCELModelExceptionwith a clear message when an Azure-shapedapi_baseis configured withoutapi_version(so misconfiguration fails loudly, not silently).Bug 3 β
LCELRetry.invoke()Drops**kwargsThatainvoke()Forwards Correctly [HIGH]File:
connectchain/lcel/lcel_retry.pySeverity: π High β Sync/async inconsistency
LCELRetry.invoke()does not forward**kwargsto the underlying runnable.ainvoke()does. Identical calls with extra kwargs behave differently depending on whether they're sync or async.Fix: Add
**kwargsforwarding toinvoke()'s call:self._runnable.invoke(input, config, **kwargs).Bug 4 β
ValidPromptTemplateSanitizes Per-Field Inputs, Not the Rendered Prompt [CRITICAL]File:
connectchain/prompts/valid_prompt_template.pySeverity: π΄ Critical β Sanitizer bypass via field splitting
The class docstring says it sanitizes "the rendered prompt". The implementation sanitizes each raw template field value individually before rendering. Disallowed content split across two template fields (e.g.,
{"word1": "bad", "word2": "word"}with template"{word1} {word2}") bypasses the sanitizer β the check runs on"bad"and"word"separately, neither triggers the filter, and the final rendered string"bad word"is never checked.Fix: Move the sanitizer call to run on the final rendered string (output of
super().format(...)).Bug 5 β
_wrap_method_'shasattr()Can Raise theValueErrorIt Was Written to Guard Against [MEDIUM]File:
connectchain/utils/llm_proxy_wrapper.pySeverity: π‘ Medium β Unexpected exception path
_wrap_method_callshasattr(llm, method_name)before thetryblock. On Pydantic v2 models (which LangChain 0.3.x uses), accessing a field that doesn't exist can trigger validators that raiseValueError. The surroundingtry/except ValueErrorwas written to catch exactly this β buthasattrruns before it.Fix: Move the
hasattrcheck inside thetryblock, or usegetattr(llm, method_name, None)with aNoneguard.Bug 6 β
SessionMap.__new__Silently Ignoresexpires_inAfter First Construction [HIGH]File:
connectchain/utils/session_map.pySeverity: π High β Wrong expiry policy applied per model
SessionMapis a singleton. After the first construction, every subsequentSessionMap(different_interval)call returns the existing instance without updatingexpires_in. If two models use different token refresh intervals, the second model's interval is silently ignored.Fix (with Round 2 correction): Capture
expires_inper-session at cache time (store it alongside the LLM innew_session()), rather than reading the singleton's currentself.expires_ininis_expired(). Makes each session's expiry policy independent.Bug 7 β Custom Exceptions Subclass
BaseExceptionInstead ofException[HIGH + SECURITY]File:
connectchain/utils/exceptions.py,connectchain/lcel/model.pySeverity: π High (3 of 4 exceptions) + π Security note (1 of 4)
LCELModelException,ConfigException, andUtilExceptionsubclassBaseExceptioninstead ofException. Standardexcept Exception:handlers will never catch them β they propagate uncaught through catch-all blocks, crashing the application.ConnectChainNoAccessExceptionis intentionallyBaseExceptionβ it is the kill-switch that disablesAPIChaininconnectchain/__init__.py. This one must remainBaseExceptionso that ordinary applicationexcept Exception:code cannot swallow it.Fix: Change
LCELModelException,ConfigException, andUtilExceptionto subclassException. Add an explanatory comment onConnectChainNoAccessExceptiondocumenting why it remainsBaseException.Bug 8 β
MCPToolLoader.load_tools([])Treats Explicit Empty List as "No Filter" [MEDIUM]File:
connectchain/tools/mcp/tool_loader.pySeverity: π‘ Medium β Semantic mismatch;
[]andNonehave opposite meaningsThe filter parameter is checked with
if not server_names:. An explicit empty list[]("load from no servers") andNone("load from all servers") both evaluate falsy β soload_tools([])loads all tools instead of none.Fix: Check
if server_names is None:to distinguish "unset" from "explicitly empty".Bug 9 β
MCPToolAgentSilently Drops Tools on Same-Name Collisions Across Servers [MEDIUM]File:
connectchain/tools/mcp/mcp_tool_agent.pySeverity: π‘ Medium β Silent data loss, no warning
When two MCP servers expose tools with the same name, the second tool silently overwrites the first in the internal tools dict. No warning is logged; the dropped tool is unrecoverable.
Fix: Log a
WARNINGwhen a name collision is detected, including both server names and the tool name.Bug 10 β
MCPToolAgent.ainvokeViolates-> dictReturn Type on No-Tool Path [MEDIUM]File:
connectchain/tools/mcp/mcp_tool_agent.pySeverity: π‘ Medium β Type contract violation
ainvokeis annotated-> dictbut returns a plainstrwhen the agent decides no tool call is needed. Callers that unpack the return value as a dict crash on the direct-answer path.Fix: Wrap the direct-answer string:
return {"output": response_text}. Updated the one existing test that relied on the old shape.Round 2 β 8-Angle Review of Round 1's Diff (3 Corrections)
Correction A β Security Regression:
ConnectChainNoAccessExceptionOver-BroadenedRound 1 grouped all four exceptions together and changed them all to
Exception. This broke theAPIChainkill-switch inconnectchain/__init__.pyβConnectChainNoAccessExceptionis specifically designed to not be catchable by ordinary application code. Reverted toBaseExceptionfor this exception only, with a comment.Correction B β
SessionMapPer-Session Expiry Fix Was IncompleteRound 1 updated
expires_inon every construction β stopping a later config from being silently ignored β butexpires_inwas still one shared singleton field applied to all cached sessions. A laterSessionMap(different_interval)for model B would retroactively change model A's effective expiry. Fixed by storingexpires_inper-session atnew_session()call time.Correction C β Azure Missing
api_versionNow Fails Silently Instead of LoudlyThe
hasattrβgetattrfix for the Azure branch changed the gating condition from "always true" (guaranteed visible crash) to "false whenapi_versionunset" β falling through toChatOpenAIpointed at the Azure endpoint, sending non-Azure-shaped requests that fail confusingly at call time. Added an explicit guard that raisesLCELModelExceptionwith a clear message.Round 3 β Hardening Pass (3 Additional Fixes)
Bug 11 β
SessionMapCrashes withNone-Valuedexpires_in[HIGH]File:
connectchain/utils/session_map.pyIf
SessionMapis constructed withexpires_in=None(from a config that doesn't set the field βConfigWrapperreturnsNonefor missing keys),is_expired()crashes withTypeError: '>' not supported between instances of 'float' and 'NoneType'.Fix: Validate
expires_inat construction time; raiseValueErrorimmediately ifNoneor non-positive.Bug 12 β Azure Detection Too Narrow: Sovereign Cloud Endpoints Not Recognized [HIGH]
File:
connectchain/lcel/model.pyAzure detection checks
"openai.azure.com" in str(api_base). Sovereign cloud endpoints (*.openai.azure.us,*.cognitive.microsoft.com) don't match, silently falling through toChatOpenAIinstead ofAzureOpenAI.Fix: Broaden the detection to cover known sovereign patterns; also support an explicit
azure: trueflag in model config as a reliable override.Bug 13 β YAML Date Coercion Breaks
api_versionString [MEDIUM]File: Config loading path (
connectchain/utils/config.py)YAML parses values like
2024-02-01asdatetime.dateobjects, not strings. Azure OpenAI API versions use this exact date format. They arrive asdateobjects and causeTypeErrorwhen passed to the Azure client.Fix: Coerce
api_versiontostrduring config loading; document the quoting requirement in the example config.Summary Table
lcel/model.pyapi_versionpassed twice toAzureOpenAIβ always crasheslcel/model.pyhasattr/getattrno-ops againstConfigWrapperβ silentNonevalueslcel/lcel_retry.pyinvoke()drops**kwargs;ainvoke()doesn't β sync/async inconsistencyprompts/valid_prompt_template.pyutils/llm_proxy_wrapper.pyhasattrbefore try-block can raise theValueErrorthe block catchesutils/session_map.pyexpires_inafter first construction β wrong expiry per modelutils/exceptions.pyBaseException; uncatchable by standardexcept Exception:tools/mcp/tool_loader.py[]treated asNone(no filter) instead of "no servers"tools/mcp/mcp_tool_agent.pytools/mcp/mcp_tool_agent.pyainvokereturnsstron no-tool path, violates-> dictannotationutils/session_map.pyNone-valuedexpires_incrashesis_expired()at runtimelcel/model.pyapi_versiondate strings asdateobjects βTypeErrorFix PR with 99 passing unit tests,
mypy-clean, andpylintscore unchanged follows.