🔴 Required Information
Describe the Bug:
_resolve_toolset_auth in base_llm_flow.py runs before every agent invocation and calls get_auth_config() on each toolset. If the toolset returns an AuthConfig, the framework checks for credentials before the LLM has decided whether any tool call is needed. When the framework-level credential lookup fails — which it always does for tool-level-authenticated credentials because the two paths use different key formats — this triggers a full OAuth redirect flow on every message, including simple greetings that will never invoke any tool.
Steps to Reproduce:
- Register an
OpenAPIToolset (or ApplicationIntegrationToolset) on an LlmAgent with OAuth2 authorizationCode flow.
- Send any message to the agent — e.g.
"hey".
- Observe: an
adk_request_credential event fires before the LLM response, producing an OAuth redirect URL.
- Complete the OAuth flow; send another message (tool-invoking or not).
- Observe: the redirect fires again, indefinitely on every subsequent message.
Expected Behavior:
OAuth should only be triggered when the LLM actually invokes a tool that requires authentication. Simple greetings or non-tool-invoking messages should proceed without any auth flow. After a successful OAuth exchange, the credential should be found and reused by subsequent tool invocations.
Observed Behavior:
OAuth redirect fires on every message regardless of whether any tool would be invoked. Even after a successful authorization, the next message triggers OAuth again because the framework-level credential lookup can't find the credential that was stored by the tool-level path under a different key format.
Environment Details:
- ADK Library Version:
google-adk 1.28.0
- Desktop OS: macOS (reproduced), Linux (expected to affect all platforms)
- Python Version: Python 3.13
Model Information:
- Are you using LiteLLM: No
- Which model is being used: gemini-2.5-flash (irrelevant — this is in the auth path, not the model path)
🟡 Optional Information
Additional Context:
Root cause — _resolve_toolset_auth in google/adk/flows/llm_flows/base_llm_flow.py (simplified for clarity; the actual implementation adds a try/except ValueError around get_auth_credential and constructs toolset_id from the toolset class name):
for tool_union in agent.tools:
if not isinstance(tool_union, BaseToolset):
continue
auth_config = tool_union.get_auth_config()
if not auth_config:
continue
credential = await CredentialManager(auth_config).get_auth_credential(
callback_context
)
if credential:
auth_config.exchanged_auth_credential = credential
else:
# triggers OAuth redirect — regardless of whether the LLM
# is going to invoke any tool from this toolset
pending_auth_requests[toolset_id] = auth_config
The framework checks unconditionally on every invocation — it doesn't know whether the upcoming LLM response will use any tool from the toolset.
Secondary issue — key format mismatch makes the preemptive check worse:
The preemptive check would be merely inefficient if it could actually find credentials stored by successful tool invocations. But ADK has two independent credential storage paths that use different key formats for the same logical credential:
| Path |
Where |
Key format |
Example |
| Framework-level |
AuthConfig.credential_key in auth_tool.py |
adk_{scheme}_{credential} |
adk_oauth2_a1b2c3d4_OAUTH2_e5f6g7h8 |
| Tool-level |
ToolContextCredentialStore.get_credential_key() in tool_auth_handler.py |
{scheme}_{credential}_existing_exchanged_credential |
oauth2_a1b2c3d4_OAUTH2_e5f6g7h8_existing_exchanged_credential |
Both use the same _stable_model_digest() for the middle segments. The wrapper differs.
The failure flow:
- First message →
_resolve_toolset_auth → load_credential(key="adk_...") → not found → triggers OAuth
- User authorizes → token exchange via
ToolAuthHandler → stored under {...}_existing_exchanged_credential
- Next message →
_resolve_toolset_auth → load_credential(key="adk_...") → still not found (credential exists under the tool-level key) → triggers OAuth again, indefinitely
Passing a custom credential_service (e.g. SessionStateCredentialService) to the Runner doesn't help — ToolContextCredentialStore bypasses the configured credential_service entirely and writes directly to tool_context.state.
The ToolContextCredentialStore source contains a TODO that acknowledges architectural tension:
# TODO try not to use session state, this looks a hacky way, depend on
# session implementation, we don't want session to persist the token,
# meanwhile we want the token shared across runs.
Proposed fix (preferred): Defer toolset auth until a tool is actually invoked. ToolAuthHandler already handles tool-level auth on demand during RestApiTool.call() and is fully self-contained — it doesn't depend on framework-level pre-population. Removing the preemptive check from _resolve_toolset_auth would:
- Fix the preemptive OAuth trigger on non-tool messages
- Eliminate per-invocation overhead
- Make the framework-level credential store optional in the OAuth path — tools authenticate themselves and use their own store
- Make the key-format mismatch a non-issue in practice
Alternative (if the framework-level path is intentional): Unify the key formats between AuthConfig.credential_key and ToolContextCredentialStore.get_credential_key() so credentials stored by one path are findable by the other:
class ToolContextCredentialStore:
def get_credential_key(self, auth_scheme, auth_credential) -> str:
auth_config = AuthConfig(
auth_scheme=auth_scheme,
raw_auth_credential=auth_credential,
)
if auth_config.credential_key:
return auth_config.credential_key
# Legacy fallback
return f"{scheme_name}_{credential_name}_existing_exchanged_credential"
Impact: Users see OAuth redirects on every single message until a workaround is applied. Messages that don't need tools (greetings, clarifying questions, general chitchat) all trigger auth flows, making OAuth-enabled agents effectively unusable without intervention.
Companion issues (filed together):
Minimal Reproduction Code:
Complete runnable reproduction — mirrors the contributing/samples/oauth2_client_credentials layout (agent.py + main.py + oauth2_test_server.py + README.md), adapted to use the authorization_code flow. A --apply-fix CLI flag monkey-patches the proposed fix so the same script demonstrates both the bug and its resolution:
https://github.com/doughayden/adk-issue-examples/tree/2f454e73c2f1885ebe9be61125e02800cb2164b3/01-preemptive_toolset_auth
Expected output — without --apply-fix a non-tool prompt ("Hi! What can you do?") emits an adk_request_credential function call before the LLM runs. With --apply-fix the LLM responds normally.
Workaround applied in our project — disable the preemptive check on the toolset instance:
salesforce_toolset.get_auth_config = lambda: None # type: ignore[method-assign]
This disables the framework-level preemptive check while leaving tool-level auth intact via ToolAuthHandler. OAuth only fires when the LLM actually invokes a tool.
Why this is safe:
get_tools() does not use exchanged_auth_credential — it returns cached RestApiTool instances filtered by tool_filter.
- Each
RestApiTool.call() runs its own ToolAuthHandler.prepare_auth_credentials() which independently checks ToolContextCredentialStore, triggers OAuth on demand, and stores credentials in session state.
- The tool-level auth path is fully self-contained — it doesn't depend on framework-level pre-population.
For projects that can't disable the preemptive check, a custom BaseCredentialService can bridge both key formats by checking the framework key first then falling back to scanning state for keys ending with _existing_exchanged_credential, injected into the Runner as credential_service.
How often has this issue occurred?: Always (100%) — reproduces on every message for any agent with an OAuth2 toolset.
🔴 Required Information
Describe the Bug:
_resolve_toolset_authinbase_llm_flow.pyruns before every agent invocation and callsget_auth_config()on each toolset. If the toolset returns anAuthConfig, the framework checks for credentials before the LLM has decided whether any tool call is needed. When the framework-level credential lookup fails — which it always does for tool-level-authenticated credentials because the two paths use different key formats — this triggers a full OAuth redirect flow on every message, including simple greetings that will never invoke any tool.Steps to Reproduce:
OpenAPIToolset(orApplicationIntegrationToolset) on anLlmAgentwith OAuth2authorizationCodeflow."hey".adk_request_credentialevent fires before the LLM response, producing an OAuth redirect URL.Expected Behavior:
OAuth should only be triggered when the LLM actually invokes a tool that requires authentication. Simple greetings or non-tool-invoking messages should proceed without any auth flow. After a successful OAuth exchange, the credential should be found and reused by subsequent tool invocations.
Observed Behavior:
OAuth redirect fires on every message regardless of whether any tool would be invoked. Even after a successful authorization, the next message triggers OAuth again because the framework-level credential lookup can't find the credential that was stored by the tool-level path under a different key format.
Environment Details:
google-adk 1.28.0Model Information:
🟡 Optional Information
Additional Context:
Root cause —
_resolve_toolset_authingoogle/adk/flows/llm_flows/base_llm_flow.py(simplified for clarity; the actual implementation adds atry/except ValueErroraroundget_auth_credentialand constructstoolset_idfrom the toolset class name):The framework checks unconditionally on every invocation — it doesn't know whether the upcoming LLM response will use any tool from the toolset.
Secondary issue — key format mismatch makes the preemptive check worse:
The preemptive check would be merely inefficient if it could actually find credentials stored by successful tool invocations. But ADK has two independent credential storage paths that use different key formats for the same logical credential:
AuthConfig.credential_keyinauth_tool.pyadk_{scheme}_{credential}adk_oauth2_a1b2c3d4_OAUTH2_e5f6g7h8ToolContextCredentialStore.get_credential_key()intool_auth_handler.py{scheme}_{credential}_existing_exchanged_credentialoauth2_a1b2c3d4_OAUTH2_e5f6g7h8_existing_exchanged_credentialBoth use the same
_stable_model_digest()for the middle segments. The wrapper differs.The failure flow:
_resolve_toolset_auth→load_credential(key="adk_...")→ not found → triggers OAuthToolAuthHandler→ stored under{...}_existing_exchanged_credential_resolve_toolset_auth→load_credential(key="adk_...")→ still not found (credential exists under the tool-level key) → triggers OAuth again, indefinitelyPassing a custom
credential_service(e.g.SessionStateCredentialService) to theRunnerdoesn't help —ToolContextCredentialStorebypasses the configuredcredential_serviceentirely and writes directly totool_context.state.The
ToolContextCredentialStoresource contains a TODO that acknowledges architectural tension:Proposed fix (preferred): Defer toolset auth until a tool is actually invoked.
ToolAuthHandleralready handles tool-level auth on demand duringRestApiTool.call()and is fully self-contained — it doesn't depend on framework-level pre-population. Removing the preemptive check from_resolve_toolset_authwould:Alternative (if the framework-level path is intentional): Unify the key formats between
AuthConfig.credential_keyandToolContextCredentialStore.get_credential_key()so credentials stored by one path are findable by the other:Impact: Users see OAuth redirects on every single message until a workaround is applied. Messages that don't need tools (greetings, clarifying questions, general chitchat) all trigger auth flows, making OAuth-enabled agents effectively unusable without intervention.
Companion issues (filed together):
scopeparameterToolAuthHandler._get_existing_credentialrefreshes OAuth2 credentials in memory but doesn't persist them #5329 — Refreshed OAuth2 credentials are not persisted to the credential storeMinimal Reproduction Code:
Complete runnable reproduction — mirrors the
contributing/samples/oauth2_client_credentialslayout (agent.py+main.py+oauth2_test_server.py+README.md), adapted to use theauthorization_codeflow. A--apply-fixCLI flag monkey-patches the proposed fix so the same script demonstrates both the bug and its resolution:https://github.com/doughayden/adk-issue-examples/tree/2f454e73c2f1885ebe9be61125e02800cb2164b3/01-preemptive_toolset_auth
Expected output — without
--apply-fixa non-tool prompt ("Hi! What can you do?") emits anadk_request_credentialfunction call before the LLM runs. With--apply-fixthe LLM responds normally.Workaround applied in our project — disable the preemptive check on the toolset instance:
This disables the framework-level preemptive check while leaving tool-level auth intact via
ToolAuthHandler. OAuth only fires when the LLM actually invokes a tool.Why this is safe:
get_tools()does not useexchanged_auth_credential— it returns cachedRestApiToolinstances filtered bytool_filter.RestApiTool.call()runs its ownToolAuthHandler.prepare_auth_credentials()which independently checksToolContextCredentialStore, triggers OAuth on demand, and stores credentials in session state.For projects that can't disable the preemptive check, a custom
BaseCredentialServicecan bridge both key formats by checking the framework key first then falling back to scanning state for keys ending with_existing_exchanged_credential, injected into theRunnerascredential_service.How often has this issue occurred?: Always (100%) — reproduces on every message for any agent with an OAuth2 toolset.