From 5d5d21556e129ecab93b38bfe0a5b776249cf5f0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:37:45 -0700 Subject: [PATCH] fix: sync client.api_key during UnicodeEncodeError ASCII recovery (#10090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing recovery block sanitized self.api_key and self._client_kwargs['api_key'] but did not update self.client.api_key. The OpenAI SDK stores its own copy of api_key and reads it dynamically via the auth_headers property on every request. Without this fix, the retry after sanitization would still send the corrupted key in the Authorization header, causing the same UnicodeEncodeError. The bug manifests when an API key contains Unicode lookalike characters (e.g. ʋ U+028B instead of v) from copy-pasting out of PDFs, rich-text editors, or web pages with decorative fonts. httpx hard-encodes all HTTP headers as ASCII, so the non-ASCII char in the Authorization header triggers the error. Adds TestApiKeyClientSync with two tests verifying: - All three key locations are synced after sanitization - Recovery handles client=None (pre-init) without crashing --- run_agent.py | 5 ++ tests/run_agent/test_unicode_ascii_codec.py | 64 +++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/run_agent.py b/run_agent.py index 55b4efaa677..48382389eaf 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9037,6 +9037,11 @@ class AIAgent: self.api_key = _clean_key if isinstance(getattr(self, "_client_kwargs", None), dict): self._client_kwargs["api_key"] = _clean_key + # Also update the live client — it holds its + # own copy of api_key which auth_headers reads + # dynamically on every request. + if getattr(self, "client", None) is not None and hasattr(self.client, "api_key"): + self.client.api_key = _clean_key _credential_sanitized = True self._vprint( f"{self.log_prefix}⚠️ API key contained non-ASCII characters " diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index ef4f3f339d4..a8a52c34ae3 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -230,3 +230,67 @@ class TestSanitizeStructureNonAscii: assert _sanitize_structure_non_ascii(payload) is True assert payload["default_headers"]["X-Title"] == "Hermes Agent" assert payload["default_headers"]["User-Agent"] == "Hermes/1.0 " + + +class TestApiKeyClientSync: + """Verify that ASCII recovery updates the live OpenAI client's api_key. + + The OpenAI SDK stores its own copy of api_key which auth_headers reads + dynamically. If only self.api_key is updated but self.client.api_key + is not, the next request still sends the corrupted key in the + Authorization header. + """ + + def test_client_api_key_updated_on_sanitize(self): + """Simulate the recovery path and verify client.api_key is synced.""" + from unittest.mock import MagicMock + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + bad_key = "sk-proj-abc\u028bdef" # ʋ lookalike at position 11 + agent.api_key = bad_key + agent._client_kwargs = {"api_key": bad_key} + agent.quiet_mode = True + + # Mock client with its own api_key attribute (like the real OpenAI client) + mock_client = MagicMock() + mock_client.api_key = bad_key + agent.client = mock_client + + # --- replicate the recovery logic from run_agent.py --- + _raw_key = agent.api_key + _clean_key = _strip_non_ascii(_raw_key) + assert _clean_key != _raw_key, "test precondition: key should have non-ASCII" + + agent.api_key = _clean_key + agent._client_kwargs["api_key"] = _clean_key + if getattr(agent, "client", None) is not None and hasattr(agent.client, "api_key"): + agent.client.api_key = _clean_key + + # All three locations should now hold the clean key + assert agent.api_key == "sk-proj-abcdef" + assert agent._client_kwargs["api_key"] == "sk-proj-abcdef" + assert agent.client.api_key == "sk-proj-abcdef" + # The bad char should be gone from all of them + assert "\u028b" not in agent.api_key + assert "\u028b" not in agent._client_kwargs["api_key"] + assert "\u028b" not in agent.client.api_key + + def test_client_none_does_not_crash(self): + """Recovery should not crash when client is None (pre-init).""" + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + bad_key = "sk-proj-\u028b" + agent.api_key = bad_key + agent._client_kwargs = {"api_key": bad_key} + agent.client = None + + _clean_key = _strip_non_ascii(bad_key) + agent.api_key = _clean_key + agent._client_kwargs["api_key"] = _clean_key + if getattr(agent, "client", None) is not None and hasattr(agent.client, "api_key"): + agent.client.api_key = _clean_key + + assert agent.api_key == "sk-proj-" + assert agent.client is None # should not have been touched