diff --git a/docs/tools/authentication.md b/docs/tools/authentication.md index 245907e11..e88e8b380 100644 --- a/docs/tools/authentication.md +++ b/docs/tools/authentication.md @@ -551,127 +551,138 @@ except Exception as e: ```py title="helpers.py" --8<-- "examples/python/snippets/tools/auth/helpers.py" ``` + === "Mock fastapi server" + + ```py title="mock_server.py (Change USER_INFO_ENDPOINT based on where you deploy userinfo endpoint.)" + --8<-- "examples/python/snippets/tools/auth/mock_server.py" + ``` + === "data model" + + ```py title="model.py" + --8<-- "examples/python/snippets/tools/auth/model.py" + ``` === "Spec" ```yaml openapi: 3.0.1 info: - title: Okta User Info API + title: User Info API version: 1.0.0 description: |- - API to retrieve user profile information based on a valid Okta OIDC Access Token. - Authentication is handled via OpenID Connect with Okta. + API to retrieve user profile information based on a valid OIDC Access Token. + Authentication is handled via OpenID Connect with Any Idp provider. contact: name: API Support email: support@example.com # Replace with actual contact if available servers: - - url: - description: Production Environment + - url: + description: Production Environment paths: - /okta-jwt-user-api: - get: - summary: Get Authenticated User Info - description: |- - Fetches profile details for the user - operationId: getUserInfo - tags: - - User Profile - security: - - okta_oidc: - - openid - - email - - profile - responses: - '200': - description: Successfully retrieved user information. - content: - application/json: - schema: - type: object - properties: - sub: - type: string - description: Subject identifier for the user. - example: "abcdefg" - name: - type: string - description: Full name of the user. - example: "Example LastName" - locale: - type: string - description: User's locale, e.g., en-US or en_US. - example: "en_US" - email: - type: string - format: email - description: User's primary email address. - example: "username@example.com" - preferred_username: - type: string - description: Preferred username of the user (often the email). - example: "username@example.com" - given_name: - type: string - description: Given name (first name) of the user. - example: "Example" - family_name: - type: string - description: Family name (last name) of the user. - example: "LastName" - zoneinfo: - type: string - description: User's timezone, e.g., America/Los_Angeles. - example: "America/Los_Angeles" - updated_at: - type: integer - format: int64 # Using int64 for Unix timestamp - description: Timestamp when the user's profile was last updated (Unix epoch time). - example: 1743617719 - email_verified: - type: boolean - description: Indicates if the user's email address has been verified. - example: true - required: - - sub - - name - - locale - - email - - preferred_username - - given_name - - family_name - - zoneinfo - - updated_at - - email_verified - '401': - description: Unauthorized. The provided Bearer token is missing, invalid, or expired. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '403': - description: Forbidden. The provided token does not have the required scopes or permissions to access this resource. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + /oidc-jwt-user-api: + get: + summary: Get Authenticated User Info + description: |- + Fetches profile details for the user + operationId: getUserInfo + tags: + - User Profile + security: + - idp_oidc: + - openid + - email + - profile + responses: + '200': + description: Successfully retrieved user information. + content: + application/json: + schema: + type: object + properties: + sub: + type: string + description: Subject identifier for the user. + example: "abcdefg" + name: + type: string + description: Full name of the user. + example: "Example LastName" + locale: + type: string + description: User's locale, e.g., en-US or en_US. + example: "en_US" + email: + type: string + format: email + description: User's primary email address. + example: "username@example.com" + preferred_username: + type: string + description: Preferred username of the user (often the email). + example: "username@example.com" + given_name: + type: string + description: Given name (first name) of the user. + example: "Example" + family_name: + type: string + description: Family name (last name) of the user. + example: "LastName" + zoneinfo: + type: string + description: User's timezone, e.g., America/Los_Angeles. + example: "America/Los_Angeles" + updated_at: + type: integer + format: int64 # Using int64 for Unix timestamp + description: Timestamp when the user's profile was last updated (Unix epoch time). + example: 1743617719 + email_verified: + type: boolean + description: Indicates if the user's email address has been verified. + example: true + required: + - sub + - name + - locale + - email + - preferred_username + - given_name + - family_name + - zoneinfo + - updated_at + - email_verified + '401': + description: Unauthorized. The provided Bearer token is missing, invalid, or expired. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden. The provided token does not have the required scopes or permissions to access this resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: - securitySchemes: - okta_oidc: + securitySchemes: + idp_oidc: type: openIdConnect - description: Authentication via Okta using OpenID Connect. Requires a Bearer Access Token. + description: Authentication via idp using OpenID Connect. Requires a Bearer Access Token. + # TODO: Change this url base on your Identity provider. (e.g: https://accounts.google.com/.well-known/openid-configuration) openIdConnectUrl: https://your-endpoint.okta.com/.well-known/openid-configuration - schemas: - Error: + schemas: + Error: type: object properties: - code: - type: string - description: An error code. - message: - type: string - description: A human-readable error message. - required: - - code - - message + code: + type: string + description: An error code. + message: + type: string + description: A human-readable error message. + required: + - code + - message ``` diff --git a/examples/python/snippets/tools/auth/agent_cli.py b/examples/python/snippets/tools/auth/agent_cli.py index cc05d5e50..a223fc8b7 100644 --- a/examples/python/snippets/tools/auth/agent_cli.py +++ b/examples/python/snippets/tools/auth/agent_cli.py @@ -1,3 +1,17 @@ +# Copyright 2025 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. + import asyncio from dotenv import load_dotenv from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService @@ -22,7 +36,7 @@ async def async_main(): artifacts_service = InMemoryArtifactService() # Create a new user session to maintain conversation state. - session = session_service.create_session( + session = await session_service.create_session( state={}, # Optional state dictionary for session-specific data app_name='my_app', # Application identifier user_id='user' # User identifier diff --git a/examples/python/snippets/tools/auth/helpers.py b/examples/python/snippets/tools/auth/helpers.py index 678f665fe..6e639a14d 100644 --- a/examples/python/snippets/tools/auth/helpers.py +++ b/examples/python/snippets/tools/auth/helpers.py @@ -1,3 +1,17 @@ +# Copyright 2025 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 google.adk.auth import AuthConfig from google.adk.events import Event import asyncio @@ -100,11 +114,11 @@ def get_function_call_auth_config(event: Event) -> AuthConfig: and event.content.parts[0] # Use content, not contents and event.content.parts[0].function_call and event.content.parts[0].function_call.args - and event.content.parts[0].function_call.args.get('auth_config') + and event.content.parts[0].function_call.args.get('authConfig') ): # Reconstruct the AuthConfig object using the dictionary provided in the arguments. # The ** operator unpacks the dictionary into keyword arguments for the constructor. return AuthConfig( - **event.content.parts[0].function_call.args.get('auth_config') + **event.content.parts[0].function_call.args.get('authConfig') ) raise ValueError(f'Cannot get auth config from event {event}') \ No newline at end of file diff --git a/examples/python/snippets/tools/auth/mock_server.py b/examples/python/snippets/tools/auth/mock_server.py new file mode 100644 index 000000000..448bf66ab --- /dev/null +++ b/examples/python/snippets/tools/auth/mock_server.py @@ -0,0 +1,164 @@ +# Copyright 2025 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 fastapi import FastAPI, Depends, HTTPException, status, Request, Header +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from typing import Optional +import httpx +from .models import UserInfo, Error + +# Create FastAPI app +app = FastAPI( + title="User Info API", + description="API to retrieve user profile information based on a valid OIDC Access Token.", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allows all origins + allow_credentials=True, + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers +) + + +@app.get( + "/oidc-jwt-user-api", + response_model=UserInfo, + responses={ + 401: {"model": Error, "description": "Unauthorized. The provided Bearer token is missing, invalid, or expired."}, + 403: {"model": Error, "description": "Forbidden. The provided token does not have the required scopes or permissions to access this resource."}, + }, + tags=["User Profile"], + summary="Get Authenticated User Info", + description="Fetches profile details for the user", + operation_id="getUserInfo", +) +async def get_user_info(request: Request, authorization: Optional[str] = Header(None)): + """ + Get authenticated user information. + + This endpoint returns the profile information of the authenticated user + based on the provided OIDC access token. + + The token must be provided in the Authorization header as a Bearer token. + """ + # TODO: configure with your userinfo endpoint depend on where you deployed. + # Okta: https://your-endpoint.okta.com/oauth2/v1/userinfo + # Google Account: https://openidconnect.googleapis.com/v1/userinfo can be found within https://accounts.google.com/.well-known/openid-configuration + USER_INFO_ENDPOINT="https://your-endpoint.okta.com/oauth2/v1/userinfo" + + # Check if Authorization header is present + if not authorization: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization header is missing", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Extract token from Authorization header + if not authorization.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization format. Use Bearer {token}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + # Make a request to UserInfo endpoint to get user info + async with httpx.AsyncClient() as client: + response = await client.get( + USER_INFO_ENDPOINT, + headers={ + "Authorization": authorization + } + ) + + # Check if the request was successful + if response.status_code != 200: + error_detail = "Failed to retrieve user information" + try: + error_data = response.json() + if "error" in error_data and "message" in error_data["error"]: + error_detail = error_data["error"]["message"] + except: + pass + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=error_detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + raise HTTPException( + status_code=response.status_code, + detail=error_detail, + ) + + # Parse the response JSON + user_data = response.json() + + # Convert the user data to a UserInfo model + user_info = UserInfo(**user_data) + return user_info + + except httpx.RequestError as e: + # Handle network errors + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Error connecting to UserInfo API: {str(e)}", + ) + except Exception as e: + # Handle validation errors + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid user data: {str(e)}", + ) + + +@app.get("/dev-ui") +async def callback(request: Request): + # Get full URL from request + url = str(request.url) + return(url) + +@app.exception_handler(HTTPException) +async def http_exception_handler(request, exc): + """Custom exception handler for HTTP exceptions.""" + if exc.status_code == 401: + return JSONResponse( + status_code=exc.status_code, + content={"code": "unauthorized", "message": exc.detail}, + ) + elif exc.status_code == 403: + return JSONResponse( + status_code=exc.status_code, + content={"code": "forbidden", "message": exc.detail}, + ) + else: + return JSONResponse( + status_code=exc.status_code, + content={"code": "error", "message": exc.detail}, + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("user_info_api_server:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/examples/python/snippets/tools/auth/model.py b/examples/python/snippets/tools/auth/model.py new file mode 100644 index 000000000..228706a37 --- /dev/null +++ b/examples/python/snippets/tools/auth/model.py @@ -0,0 +1,35 @@ +# Copyright 2025 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 pydantic import BaseModel, EmailStr, Field + + +class UserInfo(BaseModel): + """User information model based on Okta OIDC response.""" + sub: str = Field(..., description="Subject identifier for the user") + name: str = Field(..., description="Full name of the user") + locale: str = Field(..., description="User's locale, e.g., en-US or en_US") + email: EmailStr = Field(..., description="User's primary email address") + preferred_username: str = Field(..., description="Preferred username of the user (often the email)") + given_name: str = Field(..., description="Given name (first name) of the user") + family_name: str = Field(..., description="Family name (last name) of the user") + zoneinfo: str = Field(..., description="User's timezone, e.g., America/Los_Angeles") + updated_at: int = Field(..., description="Timestamp when the user's profile was last updated (Unix epoch time)") + email_verified: bool = Field(..., description="Indicates if the user's email address has been verified") + + +class Error(BaseModel): + """Error response model based on the OpenAPI specification.""" + code: str = Field(..., description="An error code") + message: str = Field(..., description="A human-readable error message") \ No newline at end of file diff --git a/examples/python/snippets/tools/auth/tools_and_agent.py b/examples/python/snippets/tools/auth/tools_and_agent.py index 08858f534..2aafe57db 100644 --- a/examples/python/snippets/tools/auth/tools_and_agent.py +++ b/examples/python/snippets/tools/auth/tools_and_agent.py @@ -1,4 +1,19 @@ +# Copyright 2025 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. + import os +import asyncio from google.adk.auth.auth_schemes import OpenIdConnectWithConfig from google.adk.auth.auth_credential import AuthCredential, AuthCredentialTypes, OAuth2Auth @@ -61,7 +76,7 @@ model='gemini-2.0-flash', name='enterprise_assistant', instruction='Help user integrate with multiple enterprise systems, including retrieving user information which may require authentication.', - tools=userinfo_toolset.get_tools(), + tools=[userinfo_toolset] ) # --- Ready for Use --- diff --git a/examples/python/snippets/tools/function-tools/func_tool.py b/examples/python/snippets/tools/function-tools/func_tool.py index c7ce9f9ff..2ee0715f8 100644 --- a/examples/python/snippets/tools/function-tools/func_tool.py +++ b/examples/python/snippets/tools/function-tools/func_tool.py @@ -16,7 +16,6 @@ from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from google.genai import types - import yfinance as yf diff --git a/examples/python/snippets/tools/overview/doc_analysis.py b/examples/python/snippets/tools/overview/doc_analysis.py index 750e5088c..97f3c5f4a 100644 --- a/examples/python/snippets/tools/overview/doc_analysis.py +++ b/examples/python/snippets/tools/overview/doc_analysis.py @@ -14,16 +14,26 @@ from google.adk.tools import ToolContext, FunctionTool from google.genai import types +from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService +from google.adk.agents.llm_agent import LlmAgent +from google.adk.sessions import InMemorySessionService +from google.adk.runners import Runner +from google.adk.memory import InMemoryMemoryService +from google.adk.sessions import Session +import asyncio +from typing import cast -def process_document( - document_name: str, analysis_query: str, tool_context: ToolContext +async def process_document( + document_name: str, + analysis_query: str, + tool_context: ToolContext ) -> dict: """Analyzes a document using context from memory.""" # 1. Load the artifact print(f"Tool: Attempting to load artifact: {document_name}") - document_part = tool_context.load_artifact(document_name) + document_part = await tool_context.load_artifact(document_name) if not document_part: return {"status": "error", "message": f"Document '{document_name}' not found."} @@ -33,16 +43,18 @@ def process_document( # 2. Search memory for related context print(f"Tool: Searching memory for context related to: '{analysis_query}'") - memory_response = tool_context.search_memory( + memory_response = await tool_context.search_memory( f"Context for analyzing document about {analysis_query}" ) memory_context = "\n".join( [ - m.events[0].content.parts[0].text + m.content.parts[0].text for m in memory_response.memories - if m.events and m.events[0].content + if m.content and m.content.parts ] - ) # Simplified extraction + ) + + # Simplified extraction print(f"Tool: Found memory context: {memory_context[:100]}...") # 3. Perform analysis (placeholder) @@ -61,10 +73,142 @@ def process_document( "version": version, } +async def read_report_and_create_part(): + """ + Reads content from report.txt, creates a Part using constructor, + and saves it as an artifact. + """ + # Read content from local report.txt file + try: + with open("report.txt", "r") as file: + report_content = file.read() + print(f"Successfully read report.txt ({len(report_content)} chars)") + except FileNotFoundError: + print("Error: report.txt file not found. Please create it first.") + return None + + # Create a types.Part object using constructor + report_part = types.Part(text=report_content) + print("Created types.Part object using constructor: types.Part(text=content)") + + # Save it as an artifact + artifact_service = InMemoryArtifactService() + result = await artifact_service.save_artifact( + app_name=APP_NAME, + user_id=USER_ID, + session_id=SESSION_ID, + filename="report.txt", + artifact=report_part + ) + + print(f"Saved report content as artifact with version: {result}") + return report_part + +async def run_prompt( + session: Session, + new_message: str, + runner: Runner, + user_id: str + ) -> Session: + content = types.Content( + role='user', parts=[types.Part.from_text(text=new_message)] + ) + print('** User says:', content.model_dump(exclude_none=True)) + async for event in runner.run_async( + user_id=user_id, + session_id=session.id, + new_message=content, + ): + if not event.content or not event.content.parts: + continue + if event.content.parts[0].text: + print(f'** {event.author}: {event.content.parts[0].text}') + elif event.content.parts[0].function_call: + print( + f'** {event.author}: fc /' + f' {event.content.parts[0].function_call.name} /' + f' {event.content.parts[0].function_call.args}\n' + ) + elif event.content.parts[0].function_response: + print( + f'** {event.author}: fr /' + f' {event.content.parts[0].function_response.name} /' + f' {event.content.parts[0].function_response.response}\n' + ) + return cast( + Session, + await runner.session_service.get_session( + app_name=runner.app_name, user_id=user_id, session_id=session.id + ), + ) doc_analysis_tool = FunctionTool(func=process_document) # In an Agent: # Assume artifact 'report.txt' was previously saved. # Assume memory service is configured and has relevant past data. -# my_agent = Agent(..., tools=[doc_analysis_tool], artifact_service=..., memory_service=...) + +APP_NAME="document_analyzes_app" +USER_ID="u_123" +SESSION_ID="s_123" +MODEL="gemini-2.0-flash" +user_question="can you help process report.txt, and analysis topic related google adk" + +async def main(): + artifact_service = InMemoryArtifactService() + + + report_part = await read_report_and_create_part() + load_artifact_result = await artifact_service.save_artifact( + app_name=APP_NAME, + user_id=USER_ID, + session_id=SESSION_ID, + filename="report.txt", + artifact = report_part + + ) + + + memory_service=InMemoryMemoryService() + session_service = InMemorySessionService() + session = await session_service.create_session( + app_name=APP_NAME, + user_id=USER_ID, + session_id=SESSION_ID + ) + content = types.Content(role='user', parts=[types.Part(text=user_question)]) + + root_agent = LlmAgent( + name="document_analyzes_agent", + description="An agent that analyzes documents using context from memory", + model=MODEL, + tools=[doc_analysis_tool] + ) + + runner = Runner( + agent=root_agent, + app_name=APP_NAME, + session_service=session_service, + artifact_service=artifact_service, + memory_service=memory_service + ) + + print(f'----Session to create memory: {session.id} ----------------------') + session = await run_prompt(session, 'adk is opensource by google', runner, USER_ID) + session = await run_prompt(session, 'Agent Development Kit (ADK) is a flexible and modular framework',runner,USER_ID) + session = await run_prompt(session, 'We love ADK',runner,USER_ID) + await memory_service.add_session_to_memory( + session + ) + + async for event in runner.run_async( + user_id=USER_ID, + session_id=SESSION_ID, + new_message=content + ): + print(event) + +if __name__ == "__main__": + asyncio.run( + main() + ) \ No newline at end of file diff --git a/examples/python/snippets/tools/overview/weather_sentiment.py b/examples/python/snippets/tools/overview/weather_sentiment.py index 835aec421..d044a9dc1 100644 --- a/examples/python/snippets/tools/overview/weather_sentiment.py +++ b/examples/python/snippets/tools/overview/weather_sentiment.py @@ -78,6 +78,7 @@ async def setup_session_and_runner(): return session, runner + # Agent Interaction async def call_agent_async(query): content = types.Content(role='user', parts=[types.Part(text=query)])