feat: Add sample agent demonstrating 2LO, 3LO, and API Key auth via GcpAuthProvider
PiperOrigin-RevId: 904823303
This commit is contained in:
committed by
Copybara-Service
parent
2565cc6872
commit
909a8c2ad4
@@ -0,0 +1,113 @@
|
||||
# GCP Auth Sample
|
||||
|
||||
Demonstrates the use of Agent Identity auth manager with an agent that queries
|
||||
Spotify and Google Maps using auth providers.
|
||||
|
||||
Use `adk web` to run API key and 2-legged oauth flows, while use the included
|
||||
custom agent web client to run 3-legged oauth flows.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Activate environment
|
||||
|
||||
```bash
|
||||
cd adk-python
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install "google-adk[agent-identity]"
|
||||
```
|
||||
|
||||
### 3. Authenticate your environment
|
||||
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_GOOGLE_CLOUD_PROJECT"
|
||||
gcloud auth application-default set-quota-project $GOOGLE_CLOUD_PROJECT
|
||||
```
|
||||
|
||||
### 4. Create auth providers
|
||||
|
||||
Refer to the [public documentation](https://cloud.google.com/iam/docs/manage-auth-providers) to create the following Agent Identity auth providers.
|
||||
|
||||
> **Note:**
|
||||
> The identity running the agent (via Application Default Credentials) must have
|
||||
> the necessary [permissions](https://docs.cloud.google.com/iam/docs/roles-permissions/iamconnectors#iamconnectors.user)
|
||||
> to retrieve credentials from these connectors. Ensure your account has the
|
||||
> necessary role to access these resources.
|
||||
|
||||
```bash
|
||||
export GOOGLE_CLOUD_LOCATION="YOUR_GOOGLE_CLOUD_LOCATION"
|
||||
export MAPS_API_AUTH_PROVIDER_ID="YOUR_MAPS_API_AUTH_PROVIDER_ID"
|
||||
export SPOTIFY_2LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_2LO_AUTH_PROVIDER_ID"
|
||||
export SPOTIFY_3LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_3LO_AUTH_PROVIDER_ID"
|
||||
|
||||
gcloud alpha agent-identity connectors create $MAPS_API_AUTH_PROVIDER_ID \
|
||||
--project=$GOOGLE_CLOUD_PROJECT \
|
||||
--location=$GOOGLE_CLOUD_LOCATION \
|
||||
--api-key=YOUR_API_KEY
|
||||
|
||||
gcloud alpha agent-identity connectors create $SPOTIFY_2LO_AUTH_PROVIDER_ID \
|
||||
--project=$GOOGLE_CLOUD_PROJECT \
|
||||
--location=$GOOGLE_CLOUD_LOCATION \
|
||||
--two-legged-oauth-client-id=OAUTH_CLIENT_ID \
|
||||
--two-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \
|
||||
--two-legged-oauth-token-endpoint=OAUTH_TOKEN_ENDPOINT
|
||||
|
||||
gcloud alpha agent-identity connectors create $SPOTIFY_3LO_AUTH_PROVIDER_ID \
|
||||
--project=$GOOGLE_CLOUD_PROJECT \
|
||||
--location=$GOOGLE_CLOUD_LOCATION \
|
||||
--three-legged-oauth-client-id=OAUTH_CLIENT_ID \
|
||||
--three-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \
|
||||
--three-legged-oauth-authorization-url=AUTHORIZATION_URL \
|
||||
--three-legged-oauth-token-url=TOKEN_URL \
|
||||
--allowed-scopes=ALLOWED_SCOPES
|
||||
```
|
||||
|
||||
### 5. Test API key and 2LO auth provider using ADK web client
|
||||
|
||||
```bash
|
||||
adk web contributing/samples
|
||||
```
|
||||
|
||||
- On the ADK web UI, select the agent named `gcp_auth` from the dropdown.
|
||||
- Sample queries to try:
|
||||
- API key (Google Maps tool): "What is the current weather in New York?"
|
||||
- 2LO key (Spotify tool): "Tell me about the song: Waving Flag"
|
||||
|
||||
### 6. Test 3LO auth provider using custom web client
|
||||
|
||||
> **Note:** If the agent backend is running on a different port or host other
|
||||
> than `localhost:8000`, please set the `AGENT_BACKEND_URL` environment variable
|
||||
> before starting the client (e.g.,
|
||||
> `export AGENT_BACKEND_URL="http://localhost:9000"`).
|
||||
|
||||
- In a separate shell, activate environment
|
||||
|
||||
```bash
|
||||
cd adk-python
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
- Navigate to the client directory and install dependencies
|
||||
|
||||
```bash
|
||||
cd contributing/samples/gcp_auth/client
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- Start the client application
|
||||
|
||||
```bash
|
||||
uvicorn main:app --port 8080 --reload
|
||||
```
|
||||
|
||||
- Open `http://localhost:8080`. (**Note:** You must use `localhost` and not `127.0.0.1`, as the OAuth redirect URL specifically requires it.)
|
||||
- On the login screen, enter an arbitrary user ID (e.g. test_user123).
|
||||
- Sample queries to try:
|
||||
- 3LO key (Spotify tool): "What are my private Spotify playlists?"
|
||||
@@ -0,0 +1,166 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from google.adk.agents import Agent
|
||||
from google.adk.apps import App
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_tool import AuthConfig
|
||||
from google.adk.auth.credential_manager import CredentialManager
|
||||
from google.adk.integrations.agent_identity import GcpAuthProvider
|
||||
from google.adk.integrations.agent_identity import GcpAuthProviderScheme
|
||||
from google.adk.tools.authenticated_function_tool import AuthenticatedFunctionTool
|
||||
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
|
||||
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
|
||||
import httpx
|
||||
|
||||
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT")
|
||||
LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION")
|
||||
MAPS_API_AUTH_PROVIDER_ID = os.environ.get("MAPS_API_AUTH_PROVIDER_ID")
|
||||
SPOTIFY_2LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_2LO_AUTH_PROVIDER_ID")
|
||||
SPOTIFY_3LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_3LO_AUTH_PROVIDER_ID")
|
||||
|
||||
MAPS_API_AUTH_PROVIDER = f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/{MAPS_API_AUTH_PROVIDER_ID}"
|
||||
SPOTIFY_2LO_AUTH_PROVIDER = f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/{SPOTIFY_2LO_AUTH_PROVIDER_ID}"
|
||||
SPOTIFY_3LO_AUTH_PROVIDER = f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/{SPOTIFY_3LO_AUTH_PROVIDER_ID}"
|
||||
|
||||
MAPS_MCP_ENDPOINT = "https://mapstools.googleapis.com/mcp"
|
||||
CONTINUE_URI = "http://localhost:8080/commit"
|
||||
MODEL = "gemini-2.5-flash"
|
||||
|
||||
|
||||
async def spotify_search_track(
|
||||
credential: AuthCredential, query: str
|
||||
) -> str | list:
|
||||
"""Searches for a track on Spotify and returns its details."""
|
||||
headers = {}
|
||||
if http := credential.http:
|
||||
if http.scheme and http.credentials and (token := http.credentials.token):
|
||||
headers["Authorization"] = f"{http.scheme.title()} {token}"
|
||||
if http.additional_headers:
|
||||
headers.update(http.additional_headers)
|
||||
|
||||
if not headers:
|
||||
return "Error: No authentication token available."
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers=headers,
|
||||
params={"q": query, "type": "track", "limit": 1},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return f"Error from Spotify API: {response.status_code} - {response.text}"
|
||||
|
||||
data = response.json()
|
||||
items = data.get("tracks", {}).get("items", [])
|
||||
|
||||
if not items:
|
||||
return f"No track found for query '{query}'."
|
||||
|
||||
return items
|
||||
|
||||
|
||||
async def spotify_get_playlists(credential: AuthCredential) -> str | list:
|
||||
"""Fetches the current user's private playlists on Spotify."""
|
||||
headers = {}
|
||||
if http := credential.http:
|
||||
if http.scheme and http.credentials and (token := http.credentials.token):
|
||||
headers["Authorization"] = f"{http.scheme.title()} {token}"
|
||||
if http.additional_headers:
|
||||
headers.update(http.additional_headers)
|
||||
|
||||
if not headers:
|
||||
return "Error: No authentication token available."
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.spotify.com/v1/me/playlists",
|
||||
headers=headers,
|
||||
params={"limit": 10},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return f"Error from Spotify API: {response.status_code} - {response.text}"
|
||||
|
||||
data = response.json()
|
||||
items = data.get("items", [])
|
||||
|
||||
if not items:
|
||||
return "No playlists found for the current user."
|
||||
|
||||
# Extract useful information
|
||||
return [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"public": item.get("public"),
|
||||
"total_tracks": item.get("tracks", {}).get("total"),
|
||||
}
|
||||
for item in items
|
||||
if item
|
||||
]
|
||||
|
||||
|
||||
spotify_auth_config_2lo = AuthConfig(
|
||||
auth_scheme=GcpAuthProviderScheme(name=SPOTIFY_2LO_AUTH_PROVIDER)
|
||||
)
|
||||
spotify_search_track_tool = AuthenticatedFunctionTool(
|
||||
func=spotify_search_track,
|
||||
auth_config=spotify_auth_config_2lo,
|
||||
)
|
||||
|
||||
spotify_auth_config_3lo = AuthConfig(
|
||||
auth_scheme=GcpAuthProviderScheme(
|
||||
name=SPOTIFY_3LO_AUTH_PROVIDER,
|
||||
scopes=["playlist-read-private"],
|
||||
continue_uri=CONTINUE_URI,
|
||||
)
|
||||
)
|
||||
spotify_get_playlist_tool = AuthenticatedFunctionTool(
|
||||
func=spotify_get_playlists,
|
||||
auth_config=spotify_auth_config_3lo,
|
||||
)
|
||||
|
||||
maps_tools = McpToolset(
|
||||
connection_params=StreamableHTTPConnectionParams(url=MAPS_MCP_ENDPOINT),
|
||||
auth_scheme=GcpAuthProviderScheme(name=MAPS_API_AUTH_PROVIDER),
|
||||
errlog=None, # Required for agent freezing (pickling)
|
||||
)
|
||||
|
||||
CredentialManager.register_auth_provider(GcpAuthProvider())
|
||||
|
||||
root_agent = Agent(
|
||||
name="gcp_auth_agent",
|
||||
model=MODEL,
|
||||
instruction=(
|
||||
"You are a Spotify and Google Maps assistant. Use your tools to "
|
||||
"search for track details, fetch the user's private playlists, "
|
||||
"and look up locations. Keep responses concise, friendly, and "
|
||||
"emoji-filled!"
|
||||
),
|
||||
tools=[
|
||||
spotify_search_track_tool,
|
||||
spotify_get_playlist_tool,
|
||||
maps_tools,
|
||||
],
|
||||
)
|
||||
|
||||
app = App(
|
||||
name="gcp_auth",
|
||||
root_agent=root_agent,
|
||||
)
|
||||
@@ -0,0 +1,566 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Gcp Auth demo</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--primary: #1db954;
|
||||
--text-main: #191414;
|
||||
--text-muted: #64748b;
|
||||
--bubble-agent: #ffffff;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
#header {
|
||||
padding: 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
#chat-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
#chat {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.msg {
|
||||
max-width: 85%;
|
||||
line-height: 1.55;
|
||||
font-size: 15px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.user {
|
||||
align-self: flex-end;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.agent {
|
||||
align-self: flex-start;
|
||||
background: var(--bubble-agent);
|
||||
border-bottom-left-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.thought {
|
||||
align-self: flex-start;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
/* Basic ASCII Thinking Animation */
|
||||
.thinking-bubble {
|
||||
align-self: flex-start;
|
||||
background: var(--bubble-agent);
|
||||
border-bottom-left-radius: 4px;
|
||||
color: #aaa;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#input-container {
|
||||
background: var(--surface);
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 24px;
|
||||
}
|
||||
#controls {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg);
|
||||
}
|
||||
input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
button {
|
||||
padding: 0 24px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- HTML BODY STARTS -->
|
||||
<!-- ========================================== -->
|
||||
<body>
|
||||
<div id="header" style="position: relative">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="vertical-align: middle; margin-right: 8px; color: var(--primary)"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13"></path>
|
||||
<circle cx="6" cy="18" r="3"></circle>
|
||||
<circle cx="18" cy="16" r="3"></circle>
|
||||
</svg>
|
||||
Spotify and Maps Assistant
|
||||
<div
|
||||
id="user-info-container"
|
||||
style="
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
id="user-badge"
|
||||
style="
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1db954;
|
||||
background: #f1f5f9;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
id="session-badge"
|
||||
style="font-size: 11px; font-weight: normal; color: #94a3b8; font-family: monospace"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="login-container"
|
||||
style="
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
box-sizing: border-box;
|
||||
"
|
||||
>
|
||||
<h2 style="margin-top: 0; color: #191414">Welcome</h2>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 24px">
|
||||
Please enter your User ID to continue.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
id="loginUserId"
|
||||
placeholder="User ID"
|
||||
style="width: 100%; box-sizing: border-box; margin-bottom: 16px"
|
||||
onkeypress="if(event.key==='Enter') login()"
|
||||
/>
|
||||
<button
|
||||
onclick="login()"
|
||||
style="
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat-wrapper" style="display: none">
|
||||
<div id="chat">
|
||||
<div class="msg agent">
|
||||
Asks questions about Spotify: song details, your private playlists (needs user consent)
|
||||
etc. Additionally can also ask questions to Google maps.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="input-container" style="display: none">
|
||||
<div id="controls">
|
||||
<input
|
||||
type="text"
|
||||
id="userInput"
|
||||
placeholder="Ask a question..."
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="sendBtn" onclick="send()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- JAVASCRIPT STARTS -->
|
||||
<!-- ========================================== -->
|
||||
<script>
|
||||
const chat = document.getElementById("chat");
|
||||
const wrapper = document.getElementById("chat-wrapper");
|
||||
const input = document.getElementById("userInput");
|
||||
const btn = document.getElementById("sendBtn");
|
||||
let thinkingDiv = null;
|
||||
let thinkingInterval = null;
|
||||
let pendingAuthCall = null;
|
||||
let currentUserId = "";
|
||||
let currentSessionId = "";
|
||||
|
||||
function login() {
|
||||
const idInput = document.getElementById("loginUserId").value.trim();
|
||||
if (!idInput) return;
|
||||
currentUserId = idInput;
|
||||
document.cookie = "user_id=" + currentUserId + "; path=/";
|
||||
|
||||
// Generate a random session ID for this specific chat session
|
||||
currentSessionId = "session_" + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
document.getElementById("login-container").style.display = "none";
|
||||
document.getElementById("chat-wrapper").style.display = "block";
|
||||
document.getElementById("input-container").style.display = "block";
|
||||
|
||||
document.getElementById("user-badge").innerText = "👤 " + currentUserId;
|
||||
document.getElementById("session-badge").innerText = "ID: " + currentSessionId;
|
||||
document.getElementById("user-info-container").style.display = "flex";
|
||||
|
||||
input.focus();
|
||||
scroll();
|
||||
}
|
||||
|
||||
const scroll = () => (wrapper.scrollTop = wrapper.scrollHeight);
|
||||
|
||||
async function resumeSession() {
|
||||
if (!pendingAuthCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
user_id: currentUserId,
|
||||
session_id: currentSessionId,
|
||||
function_response: {
|
||||
name: "adk_request_credential",
|
||||
id: pendingAuthCall.id,
|
||||
response: pendingAuthCall.args.authConfig,
|
||||
},
|
||||
};
|
||||
|
||||
const cardId = "auth-card-" + pendingAuthCall.id;
|
||||
const authCard = document.getElementById(cardId);
|
||||
if (authCard) {
|
||||
authCard.remove();
|
||||
} else {
|
||||
// Fallback cleanup just in case
|
||||
const cards = document.querySelectorAll('div[id^="auth-card-"]');
|
||||
cards.forEach((card) => card.remove());
|
||||
}
|
||||
|
||||
pendingAuthCall = null;
|
||||
|
||||
const tempDiv = add("Authenticating and syncing with Spotify...", "thought");
|
||||
setThinking(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
tempDiv.remove(); // Remove the sync message once the response starts
|
||||
await processStream(response);
|
||||
} catch (err) {
|
||||
tempDiv.remove();
|
||||
add("Error resuming session.", "agent");
|
||||
} finally {
|
||||
setThinking(false);
|
||||
input.disabled = false;
|
||||
btn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function add(text, type, isHTML = false) {
|
||||
const div = document.createElement("div");
|
||||
div.className = type === "user" ? "msg user" : type === "thought" ? "thought" : "msg agent";
|
||||
if (isHTML) div.innerHTML = text;
|
||||
else div.innerText = text;
|
||||
chat.appendChild(div);
|
||||
scroll();
|
||||
return div;
|
||||
}
|
||||
|
||||
function setThinking(active) {
|
||||
if (active) {
|
||||
if (thinkingDiv) return;
|
||||
thinkingDiv = document.createElement("div");
|
||||
thinkingDiv.className = "msg thinking-bubble";
|
||||
chat.appendChild(thinkingDiv);
|
||||
let dots = 0;
|
||||
thinkingInterval = setInterval(() => {
|
||||
dots = (dots + 1) % 4;
|
||||
thinkingDiv.innerText = ".".repeat(dots).padEnd(3, " ");
|
||||
}, 400);
|
||||
} else if (thinkingDiv) {
|
||||
clearInterval(thinkingInterval);
|
||||
thinkingDiv.remove();
|
||||
thinkingDiv = null;
|
||||
}
|
||||
scroll();
|
||||
}
|
||||
|
||||
async function processStream(response) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let currentAgentDiv = null;
|
||||
let fullText = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const json = JSON.parse(line.slice(6).trim());
|
||||
if (json.content?.parts) {
|
||||
for (const p of json.content.parts) {
|
||||
const fc = p.functionCall || p.function_call;
|
||||
if (fc) {
|
||||
if (fc.name === "adk_request_credential") {
|
||||
setThinking(false);
|
||||
pendingAuthCall = fc;
|
||||
const args = fc.args || {};
|
||||
const authConfig = args.authConfig || args.auth_config;
|
||||
|
||||
if (!authConfig) {
|
||||
console.error("Missing auth config in args:", args);
|
||||
add(
|
||||
"Error: Received credential request but missing auth configuration.",
|
||||
"agent",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const authUri = authConfig.exchangedAuthCredential?.oauth2?.authUri;
|
||||
|
||||
const nonce = authConfig.exchangedAuthCredential?.oauth2?.nonce;
|
||||
|
||||
if (nonce) {
|
||||
document.cookie = "consent_nonce=" + nonce + "; path=/";
|
||||
}
|
||||
|
||||
// Create a standalone authorization card outside the standard 'msg' styling
|
||||
const authDiv = document.createElement("div");
|
||||
authDiv.id = "auth-card-" + pendingAuthCall.id;
|
||||
authDiv.style.cssText =
|
||||
"background-color: #fffbeb; border: 1px solid #fde68a; padding: 20px; border-radius: 12px; margin: 12px auto; max-width: 100%; box-shadow: 0 2px 4px rgba(0,0,0,0.05); align-self: stretch;";
|
||||
|
||||
// Disable input and button
|
||||
input.disabled = true;
|
||||
btn.disabled = true;
|
||||
|
||||
// Define the cancel function globally or attach it to the window so the inline onclick can reach it
|
||||
window.cancelAuth = function () {
|
||||
if (pendingAuthCall) {
|
||||
resumeSession();
|
||||
}
|
||||
};
|
||||
|
||||
// Define the function to open the popup and poll for closure
|
||||
window.openAuthPopup = function (url, buttonElement) {
|
||||
if (buttonElement) {
|
||||
buttonElement.innerText = "Waiting for authorization...";
|
||||
buttonElement.style.opacity = "0.7";
|
||||
buttonElement.style.cursor = "wait";
|
||||
const cancelBtn = buttonElement.nextElementSibling;
|
||||
if (cancelBtn) {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.style.opacity = "0.5";
|
||||
}
|
||||
}
|
||||
|
||||
const popup = window.open(url, "_blank");
|
||||
|
||||
if (!popup) {
|
||||
alert("Popup blocked! Please allow popups for this site.");
|
||||
if (buttonElement) {
|
||||
buttonElement.innerText = "Authorize to continue";
|
||||
buttonElement.style.opacity = "1";
|
||||
buttonElement.style.cursor = "pointer";
|
||||
}
|
||||
} else {
|
||||
// The polling loop watches for the window to close
|
||||
const timer = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(timer);
|
||||
// The popup closed, either via success script or user canceling.
|
||||
// We resume the session, and ADK backend will handle whether a token exists.
|
||||
if (pendingAuthCall) {
|
||||
resumeSession();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
authDiv.innerHTML = `
|
||||
<div style="text-align: left;">
|
||||
<h3 style="color: #78350f; font-weight: 600; margin: 0 0 8px 0; font-size: 15px;">
|
||||
⚠️ Secure Authorization Required
|
||||
</h3>
|
||||
<p style="color: #92400e; font-size: 14px; margin: 0 0 16px 0; line-height: 1.5;">
|
||||
The agent is blocked because it needs access to your Spotify account. You must authenticate to continue.
|
||||
</p>
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<button onclick="window.openAuthPopup('${authUri}', this)" style="display: inline-block; background-color: #2563eb; color: white; font-weight: 500; padding: 8px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; transition: background-color 0.2s;">
|
||||
Authorize to continue
|
||||
</button>
|
||||
<button onclick="window.cancelAuth()" style="display: inline-block; background-color: transparent; color: #64748b; font-weight: 500; padding: 8px 16px; border: 1px solid #cbd5e1; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.2s;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chat.appendChild(authDiv);
|
||||
scroll();
|
||||
currentAgentDiv = null;
|
||||
} else {
|
||||
// The ADK backend sends the tool call payload multiple times.
|
||||
// We only log it once per tool call.
|
||||
if (json.partial === false) {
|
||||
continue;
|
||||
}
|
||||
add("⚙️ Tool call: " + fc.name, "thought");
|
||||
currentAgentDiv = null;
|
||||
}
|
||||
}
|
||||
if (p.text) {
|
||||
// The ADK backend sends the full accumulated text in the final "partial: false" event.
|
||||
// Since we already append the deltas ("partial: true"), we should ignore the final one to avoid doubling the text.
|
||||
if (json.partial === false && fullText.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setThinking(false);
|
||||
if (!currentAgentDiv) {
|
||||
currentAgentDiv = add("", "agent");
|
||||
fullText = "";
|
||||
}
|
||||
fullText += p.text;
|
||||
currentAgentDiv.innerText = fullText;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("JSON Parse Error on line:", line, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
scroll();
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
|
||||
add(val, "user");
|
||||
input.value = "";
|
||||
input.disabled = true;
|
||||
btn.disabled = true;
|
||||
setThinking(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
message: val,
|
||||
user_id: currentUserId,
|
||||
session_id: currentSessionId,
|
||||
}),
|
||||
});
|
||||
await processStream(response);
|
||||
} catch (err) {
|
||||
setThinking(false);
|
||||
add("Error connecting to service.", "agent");
|
||||
} finally {
|
||||
setThinking(false);
|
||||
input.disabled = false;
|
||||
btn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
input.onkeypress = (e) => {
|
||||
if (e.key === "Enter") send();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, stream=sys.stdout, format="%(levelname)s: %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("google_adk." + __name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
AGENT_URL = os.environ.get("AGENT_BACKEND_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def ui():
|
||||
with open("index.html", "r") as f:
|
||||
return HTMLResponse(content=f.read())
|
||||
|
||||
|
||||
@app.post("/chat")
|
||||
async def chat(request: Request):
|
||||
data = await request.json()
|
||||
message = data.get("message")
|
||||
function_response = data.get("function_response")
|
||||
|
||||
app_name = "gcp_auth"
|
||||
user_id = data.get("user_id", "test_user")
|
||||
session_id = data.get("session_id", "default_session_id")
|
||||
|
||||
payload = {
|
||||
"appName": app_name,
|
||||
"userId": user_id,
|
||||
"sessionId": session_id,
|
||||
"streaming": True,
|
||||
}
|
||||
|
||||
if message:
|
||||
payload["newMessage"] = {
|
||||
"role": "user",
|
||||
"parts": [{"text": message}],
|
||||
}
|
||||
elif function_response:
|
||||
payload["newMessage"] = {
|
||||
"role": "user",
|
||||
"parts": [{"functionResponse": function_response}],
|
||||
}
|
||||
|
||||
# Ensure the session exists before we try to continue it via /run_sse
|
||||
async def proxy_stream():
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
# Attempt to create the session (ignoring if it already exists or fails quietly)
|
||||
await client.post(
|
||||
f"{AGENT_URL}/apps/{app_name}/users/{user_id}/sessions/{session_id}"
|
||||
)
|
||||
|
||||
async with client.stream(
|
||||
"POST", f"{AGENT_URL}/run_sse", json=payload
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
err = await r.aread()
|
||||
yield f"data: {json.dumps({'error': err.decode()})}\n\n"
|
||||
return
|
||||
|
||||
async for line in r.aiter_lines():
|
||||
if line:
|
||||
yield f"data: {line}\n\n" if line.startswith("{") else f"{line}\n\n"
|
||||
|
||||
return StreamingResponse(proxy_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.api_route("/commit", methods=["GET"])
|
||||
async def commit(request: Request):
|
||||
connector = request.query_params.get("connector_name")
|
||||
payload = {
|
||||
"userId": request.cookies.get("user_id"),
|
||||
"userIdValidationState": request.query_params.get(
|
||||
"user_id_validation_state"
|
||||
),
|
||||
"consentNonce": request.cookies.get("consent_nonce"),
|
||||
}
|
||||
|
||||
url = f"https://iamconnectorcredentials.googleapis.com/v1alpha/{connector}/credentials:finalize"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
err_text = e.response.text if hasattr(e, "response") else str(e)
|
||||
status = e.response.status_code if hasattr(e, "response") else 500
|
||||
logger.error(f"Commit failed: {err_text}")
|
||||
return HTMLResponse(err_text, status_code=status)
|
||||
|
||||
return HTMLResponse("""
|
||||
<script>
|
||||
window.close();
|
||||
</script>
|
||||
<p>Success. You can close this window.</p>
|
||||
""")
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
google-auth
|
||||
Reference in New Issue
Block a user