Skip to content

Commit 7e8eeca

Browse files
GWealecopybara-github
authored andcommitted
fix: Add a FastAPI endpoint for saving artifacts
This change adds new `POST` endpoint `/apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts` to the ADK web server. This endpoint lets clients to save new artifacts associated with a specific session. The endpoint uses `SaveArtifactRequest` and returns `SaveArtifactResponse`, including the version and canonical URI of the saved artifact. Close #1975 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 838977880
1 parent ed9da3f commit 7e8eeca

File tree

8 files changed

+363
-57
lines changed

8 files changed

+363
-57
lines changed

src/google/adk/artifacts/file_artifact_service.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from pydantic import ValidationError
3333
from typing_extensions import override
3434

35+
from ..errors.input_validation_error import InputValidationError
3536
from .base_artifact_service import ArtifactVersion
3637
from .base_artifact_service import BaseArtifactService
3738

@@ -100,14 +101,14 @@ def _resolve_scoped_artifact_path(
100101
to `scope_root`.
101102
102103
Raises:
103-
ValueError: If `filename` resolves outside of `scope_root`.
104+
InputValidationError: If `filename` resolves outside of `scope_root`.
104105
"""
105106
stripped = _strip_user_namespace(filename).strip()
106107
pure_path = _to_posix_path(stripped)
107108

108109
scope_root_resolved = scope_root.resolve(strict=False)
109110
if pure_path.is_absolute():
110-
raise ValueError(
111+
raise InputValidationError(
111112
f"Absolute artifact filename {filename!r} is not permitted; "
112113
"provide a path relative to the storage scope."
113114
)
@@ -118,7 +119,7 @@ def _resolve_scoped_artifact_path(
118119
try:
119120
relative = candidate.relative_to(scope_root_resolved)
120121
except ValueError as exc:
121-
raise ValueError(
122+
raise InputValidationError(
122123
f"Artifact filename {filename!r} escapes storage directory "
123124
f"{scope_root_resolved}"
124125
) from exc
@@ -230,7 +231,7 @@ def _scope_root(
230231
if _is_user_scoped(session_id, filename):
231232
return _user_artifacts_dir(base)
232233
if not session_id:
233-
raise ValueError(
234+
raise InputValidationError(
234235
"Session ID must be provided for session-scoped artifacts."
235236
)
236237
return _session_artifacts_dir(base, session_id)
@@ -371,7 +372,9 @@ def _save_artifact_sync(
371372
content_path.write_text(artifact.text, encoding="utf-8")
372373
mime_type = None
373374
else:
374-
raise ValueError("Artifact must have either inline_data or text content.")
375+
raise InputValidationError(
376+
"Artifact must have either inline_data or text content."
377+
)
375378

376379
canonical_uri = self._canonical_uri(
377380
user_id=user_id,

src/google/adk/artifacts/gcs_artifact_service.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from google.genai import types
3131
from typing_extensions import override
3232

33+
from ..errors.input_validation_error import InputValidationError
3334
from .base_artifact_service import ArtifactVersion
3435
from .base_artifact_service import BaseArtifactService
3536

@@ -161,7 +162,7 @@ def _get_blob_prefix(
161162
return f"{app_name}/{user_id}/user/{filename}"
162163

163164
if session_id is None:
164-
raise ValueError(
165+
raise InputValidationError(
165166
"Session ID must be provided for session-scoped artifacts."
166167
)
167168
return f"{app_name}/{user_id}/{session_id}/{filename}"
@@ -230,7 +231,9 @@ def _save_artifact(
230231
" GcsArtifactService."
231232
)
232233
else:
233-
raise ValueError("Artifact must have either inline_data or text.")
234+
raise InputValidationError(
235+
"Artifact must have either inline_data or text."
236+
)
234237

235238
return version
236239

src/google/adk/artifacts/in_memory_artifact_service.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
from typing import Any
1919
from typing import Optional
2020

21-
from google.adk.artifacts import artifact_util
2221
from google.genai import types
2322
from pydantic import BaseModel
2423
from pydantic import Field
2524
from typing_extensions import override
2625

26+
from . import artifact_util
27+
from ..errors.input_validation_error import InputValidationError
2728
from .base_artifact_service import ArtifactVersion
2829
from .base_artifact_service import BaseArtifactService
2930

@@ -86,7 +87,7 @@ def _artifact_path(
8687
return f"{app_name}/{user_id}/user/{filename}"
8788

8889
if session_id is None:
89-
raise ValueError(
90+
raise InputValidationError(
9091
"Session ID must be provided for session-scoped artifacts."
9192
)
9293
return f"{app_name}/{user_id}/{session_id}/{filename}"
@@ -125,15 +126,15 @@ async def save_artifact(
125126
elif artifact.file_data is not None:
126127
if artifact_util.is_artifact_ref(artifact):
127128
if not artifact_util.parse_artifact_uri(artifact.file_data.file_uri):
128-
raise ValueError(
129+
raise InputValidationError(
129130
f"Invalid artifact reference URI: {artifact.file_data.file_uri}"
130131
)
131132
# If it's a valid artifact URI, we store the artifact part as-is.
132133
# And we don't know the mime type until we load it.
133134
else:
134135
artifact_version.mime_type = artifact.file_data.mime_type
135136
else:
136-
raise ValueError("Not supported artifact type.")
137+
raise InputValidationError("Not supported artifact type.")
137138

138139
self.artifacts[path].append(
139140
_ArtifactEntry(data=artifact, artifact_version=artifact_version)
@@ -172,7 +173,7 @@ async def load_artifact(
172173
artifact_data.file_data.file_uri
173174
)
174175
if not parsed_uri:
175-
raise ValueError(
176+
raise InputValidationError(
176177
"Invalid artifact reference URI:"
177178
f" {artifact_data.file_data.file_uri}"
178179
)

src/google/adk/cli/adk_web_server.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@
6161
from ..agents.run_config import RunConfig
6262
from ..agents.run_config import StreamingMode
6363
from ..apps.app import App
64+
from ..artifacts.base_artifact_service import ArtifactVersion
6465
from ..artifacts.base_artifact_service import BaseArtifactService
6566
from ..auth.credential_service.base_credential_service import BaseCredentialService
6667
from ..errors.already_exists_error import AlreadyExistsError
68+
from ..errors.input_validation_error import InputValidationError
6769
from ..errors.not_found_error import NotFoundError
6870
from ..evaluation.base_eval_service import InferenceConfig
6971
from ..evaluation.base_eval_service import InferenceRequest
@@ -194,6 +196,19 @@ class CreateSessionRequest(common.BaseModel):
194196
)
195197

196198

199+
class SaveArtifactRequest(common.BaseModel):
200+
"""Request payload for saving a new artifact."""
201+
202+
filename: str = Field(description="Artifact filename.")
203+
artifact: types.Part = Field(
204+
description="Artifact payload encoded as google.genai.types.Part."
205+
)
206+
custom_metadata: Optional[dict[str, Any]] = Field(
207+
default=None,
208+
description="Optional metadata to associate with the artifact version.",
209+
)
210+
211+
197212
class AddSessionToEvalSetRequest(common.BaseModel):
198213
eval_id: str
199214
session_id: str
@@ -1316,6 +1331,53 @@ async def load_artifact_version(
13161331
raise HTTPException(status_code=404, detail="Artifact not found")
13171332
return artifact
13181333

1334+
@app.post(
1335+
"/apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts",
1336+
response_model=ArtifactVersion,
1337+
response_model_exclude_none=True,
1338+
)
1339+
async def save_artifact(
1340+
app_name: str,
1341+
user_id: str,
1342+
session_id: str,
1343+
req: SaveArtifactRequest,
1344+
) -> ArtifactVersion:
1345+
try:
1346+
version = await self.artifact_service.save_artifact(
1347+
app_name=app_name,
1348+
user_id=user_id,
1349+
session_id=session_id,
1350+
filename=req.filename,
1351+
artifact=req.artifact,
1352+
custom_metadata=req.custom_metadata,
1353+
)
1354+
except InputValidationError as ive:
1355+
raise HTTPException(status_code=400, detail=str(ive)) from ive
1356+
except Exception as exc: # pylint: disable=broad-exception-caught
1357+
logger.error(
1358+
"Internal error while saving artifact %s for app=%s user=%s"
1359+
" session=%s: %s",
1360+
req.filename,
1361+
app_name,
1362+
user_id,
1363+
session_id,
1364+
exc,
1365+
exc_info=True,
1366+
)
1367+
raise HTTPException(status_code=500, detail=str(exc)) from exc
1368+
artifact_version = await self.artifact_service.get_artifact_version(
1369+
app_name=app_name,
1370+
user_id=user_id,
1371+
session_id=session_id,
1372+
filename=req.filename,
1373+
version=version,
1374+
)
1375+
if artifact_version is None:
1376+
raise HTTPException(
1377+
status_code=500, detail="Artifact metadata unavailable"
1378+
)
1379+
return artifact_version
1380+
13191381
@app.get(
13201382
"/apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts",
13211383
response_model_exclude_none=True,

src/google/adk/cli/fast_api.py

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import importlib
1718
import json
1819
import logging
1920
import os
@@ -34,22 +35,43 @@
3435
from starlette.types import Lifespan
3536
from watchdog.observers import Observer
3637

38+
from ..artifacts.in_memory_artifact_service import InMemoryArtifactService
3739
from ..auth.credential_service.in_memory_credential_service import InMemoryCredentialService
3840
from ..evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager
3941
from ..evaluation.local_eval_sets_manager import LocalEvalSetsManager
42+
from ..memory.in_memory_memory_service import InMemoryMemoryService
4043
from ..runners import Runner
44+
from ..sessions.in_memory_session_service import InMemorySessionService
4145
from .adk_web_server import AdkWebServer
46+
from .service_registry import get_service_registry
4247
from .service_registry import load_services_module
4348
from .utils import envs
4449
from .utils import evals
4550
from .utils.agent_change_handler import AgentChangeEventHandler
4651
from .utils.agent_loader import AgentLoader
47-
from .utils.service_factory import create_artifact_service_from_options
48-
from .utils.service_factory import create_memory_service_from_options
49-
from .utils.service_factory import create_session_service_from_options
5052

5153
logger = logging.getLogger("google_adk." + __name__)
5254

55+
_LAZY_SERVICE_IMPORTS: dict[str, str] = {
56+
"AgentLoader": ".utils.agent_loader",
57+
"InMemoryArtifactService": "..artifacts.in_memory_artifact_service",
58+
"InMemoryMemoryService": "..memory.in_memory_memory_service",
59+
"InMemorySessionService": "..sessions.in_memory_session_service",
60+
"LocalEvalSetResultsManager": "..evaluation.local_eval_set_results_manager",
61+
"LocalEvalSetsManager": "..evaluation.local_eval_sets_manager",
62+
}
63+
64+
65+
def __getattr__(name: str):
66+
"""Lazily import defaults so patching in tests keeps working."""
67+
if name not in _LAZY_SERVICE_IMPORTS:
68+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
69+
70+
module = importlib.import_module(_LAZY_SERVICE_IMPORTS[name], __package__)
71+
attr = getattr(module, name)
72+
globals()[name] = attr
73+
return attr
74+
5375

5476
def get_fast_api_app(
5577
*,
@@ -73,8 +95,6 @@ def get_fast_api_app(
7395
logo_text: Optional[str] = None,
7496
logo_image_url: Optional[str] = None,
7597
) -> FastAPI:
76-
# Convert to absolute path for consistency
77-
agents_dir = str(Path(agents_dir).resolve())
7898

7999
# Set up eval managers.
80100
if eval_storage_uri:
@@ -92,30 +112,48 @@ def get_fast_api_app(
92112
# Load services.py from agents_dir for custom service registration.
93113
load_services_module(agents_dir)
94114

115+
service_registry = get_service_registry()
116+
95117
# Build the Memory service
96-
try:
97-
memory_service = create_memory_service_from_options(
98-
base_dir=agents_dir,
99-
memory_service_uri=memory_service_uri,
118+
if memory_service_uri:
119+
memory_service = service_registry.create_memory_service(
120+
memory_service_uri, agents_dir=agents_dir
100121
)
101-
except ValueError as exc:
102-
raise click.ClickException(str(exc)) from exc
122+
if not memory_service:
123+
raise click.ClickException(
124+
"Unsupported memory service URI: %s" % memory_service_uri
125+
)
126+
else:
127+
memory_service = InMemoryMemoryService()
103128

104129
# Build the Session service
105-
session_service = create_session_service_from_options(
106-
base_dir=agents_dir,
107-
session_service_uri=session_service_uri,
108-
session_db_kwargs=session_db_kwargs,
109-
)
130+
if session_service_uri:
131+
session_kwargs = session_db_kwargs or {}
132+
session_service = service_registry.create_session_service(
133+
session_service_uri, agents_dir=agents_dir, **session_kwargs
134+
)
135+
if not session_service:
136+
# Fallback to DatabaseSessionService if the service registry doesn't
137+
# support the session service URI scheme.
138+
from ..sessions.database_session_service import DatabaseSessionService
139+
140+
session_service = DatabaseSessionService(
141+
db_url=session_service_uri, **session_kwargs
142+
)
143+
else:
144+
session_service = InMemorySessionService()
110145

111146
# Build the Artifact service
112-
try:
113-
artifact_service = create_artifact_service_from_options(
114-
base_dir=agents_dir,
115-
artifact_service_uri=artifact_service_uri,
147+
if artifact_service_uri:
148+
artifact_service = service_registry.create_artifact_service(
149+
artifact_service_uri, agents_dir=agents_dir
116150
)
117-
except ValueError as exc:
118-
raise click.ClickException(str(exc)) from exc
151+
if not artifact_service:
152+
raise click.ClickException(
153+
"Unsupported artifact service URI: %s" % artifact_service_uri
154+
)
155+
else:
156+
artifact_service = InMemoryArtifactService()
119157

120158
# Build the Credential service
121159
credential_service = InMemoryCredentialService()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
18+
class InputValidationError(ValueError):
19+
"""Represents an error raised when user input fails validation."""
20+
21+
def __init__(self, message="Invalid input."):
22+
"""Initializes the InputValidationError exception.
23+
24+
Args:
25+
message (str): A message describing why the input is invalid.
26+
"""
27+
self.message = message
28+
super().__init__(self.message)

0 commit comments

Comments
 (0)