chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"""Promptfoo Python providers for the Google ADK integration example."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from google.adk.artifacts import InMemoryArtifactService
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.genai import types
|
||||
from opentelemetry import context as otel_context
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.propagate import extract
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
|
||||
EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
if str(EXAMPLE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(EXAMPLE_DIR))
|
||||
|
||||
from agent import APP_NAME, build_conversational_app, build_workflow_app
|
||||
|
||||
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||
DEFAULT_USER_ID = "promptfoo-user"
|
||||
DEFAULT_OTLP_ENDPOINT = "http://localhost:4318"
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TracerProviderState:
|
||||
provider: TracerProvider | None = None
|
||||
otlp_endpoint: str | None = None
|
||||
|
||||
|
||||
_tracer_provider_state = _TracerProviderState()
|
||||
|
||||
|
||||
def _build_steps(prompt: str, vars_dict: dict[str, Any]) -> list[str]:
|
||||
raw_steps = vars_dict.get("steps_json")
|
||||
if not raw_steps:
|
||||
return [prompt]
|
||||
|
||||
try:
|
||||
steps = json.loads(raw_steps)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("steps_json must be a JSON array of strings") from exc
|
||||
if not isinstance(steps, list) or not all(isinstance(step, str) for step in steps):
|
||||
raise ValueError("steps_json must be a JSON array of strings")
|
||||
return steps
|
||||
|
||||
|
||||
def _session_id(context: dict[str, Any], vars_dict: dict[str, Any]) -> str:
|
||||
if explicit := vars_dict.get("session_id"):
|
||||
return str(explicit)
|
||||
|
||||
evaluation_id = context.get("evaluationId", "local-eval")
|
||||
test_case_id = context.get("testCaseId", "default-test")
|
||||
repeat_index = context.get("repeatIndex")
|
||||
if repeat_index is None:
|
||||
return f"promptfoo-adk-{evaluation_id}-{test_case_id}"
|
||||
return f"promptfoo-adk-{evaluation_id}-{test_case_id}-repeat-{repeat_index}"
|
||||
|
||||
|
||||
def _model(options: dict[str, Any]) -> str:
|
||||
config = options.get("config", {})
|
||||
return str(config.get("model") or os.getenv("ADK_MODEL") or DEFAULT_MODEL)
|
||||
|
||||
|
||||
def _otlp_endpoint(options: dict[str, Any]) -> str:
|
||||
config = options.get("config", {})
|
||||
return str(config.get("otlp_endpoint") or DEFAULT_OTLP_ENDPOINT)
|
||||
|
||||
|
||||
def _ensure_tracer_provider(otlp_endpoint: str) -> TracerProvider:
|
||||
"""Install a TracerProvider that exports to ``otlp_endpoint`` exactly once.
|
||||
|
||||
OpenTelemetry's global ``set_tracer_provider`` is set-once: a subsequent
|
||||
call is silently rejected with an "Overriding of current TracerProvider is
|
||||
not allowed" warning. We mirror that by installing on the first call and
|
||||
refusing to swap endpoints later, which avoids stacking a second
|
||||
SimpleSpanProcessor onto the same provider (which would cause every span
|
||||
to be exported twice).
|
||||
"""
|
||||
|
||||
if _tracer_provider_state.provider is not None:
|
||||
if _tracer_provider_state.otlp_endpoint != otlp_endpoint:
|
||||
_logger.warning(
|
||||
"TracerProvider already configured for %s; ignoring request to switch to %s",
|
||||
_tracer_provider_state.otlp_endpoint,
|
||||
otlp_endpoint,
|
||||
)
|
||||
return _tracer_provider_state.provider
|
||||
|
||||
exporter = OTLPSpanExporter(endpoint=f"{otlp_endpoint.rstrip('/')}/v1/traces")
|
||||
provider = TracerProvider(
|
||||
resource=Resource.create({"service.name": "promptfoo-google-adk-example"})
|
||||
)
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
_tracer_provider_state.provider = provider
|
||||
_tracer_provider_state.otlp_endpoint = otlp_endpoint
|
||||
return provider
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _provider_span(context: dict[str, Any], otlp_endpoint: str) -> Iterator[None]:
|
||||
provider = _ensure_tracer_provider(otlp_endpoint)
|
||||
tracer = trace.get_tracer("promptfoo.google-adk.example", "1.0.0")
|
||||
parent_context = extract({"traceparent": context.get("traceparent", "")})
|
||||
token = otel_context.attach(parent_context)
|
||||
try:
|
||||
with tracer.start_as_current_span(
|
||||
"promptfoo_google_adk_provider",
|
||||
attributes={
|
||||
"promptfoo.eval.id": str(context.get("evaluationId") or ""),
|
||||
"promptfoo.test.id": str(context.get("testCaseId") or ""),
|
||||
},
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
provider.force_flush()
|
||||
|
||||
|
||||
def _final_text_from_events(events: list[Any]) -> str:
|
||||
for event in reversed(events):
|
||||
if (
|
||||
not event.is_final_response()
|
||||
or not event.content
|
||||
or not event.content.parts
|
||||
):
|
||||
continue
|
||||
text = "".join(part.text or "" for part in event.content.parts)
|
||||
if text.strip():
|
||||
return text.strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def _artifact_payloads(
|
||||
artifact_service: InMemoryArtifactService,
|
||||
session_id: str,
|
||||
) -> dict[str, str]:
|
||||
payloads: dict[str, str] = {}
|
||||
artifact_names = await artifact_service.list_artifact_keys(
|
||||
app_name=APP_NAME,
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
)
|
||||
for artifact_name in artifact_names:
|
||||
artifact = await artifact_service.load_artifact(
|
||||
app_name=APP_NAME,
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
filename=artifact_name,
|
||||
)
|
||||
if artifact is None:
|
||||
continue
|
||||
if artifact.text:
|
||||
payloads[artifact_name] = artifact.text
|
||||
elif artifact.inline_data and artifact.inline_data.data:
|
||||
payloads[artifact_name] = artifact.inline_data.data.decode("utf-8")
|
||||
return payloads
|
||||
|
||||
|
||||
async def _run_conversational_provider(
|
||||
prompt: str,
|
||||
options: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
vars_dict = context.get("vars", {})
|
||||
steps = _build_steps(prompt, vars_dict)
|
||||
session_id = _session_id(context, vars_dict)
|
||||
session_service = InMemorySessionService()
|
||||
artifact_service = InMemoryArtifactService()
|
||||
await session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
state={},
|
||||
)
|
||||
|
||||
app, audit_plugin = build_conversational_app(_model(options))
|
||||
async with Runner(
|
||||
app=app,
|
||||
session_service=session_service,
|
||||
artifact_service=artifact_service,
|
||||
) as runner:
|
||||
for step in steps:
|
||||
async for _ in runner.run_async(
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
new_message=types.Content(role="user", parts=[types.Part(text=step)]),
|
||||
):
|
||||
pass
|
||||
|
||||
session = await session_service.get_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
)
|
||||
if session is None:
|
||||
raise RuntimeError(f"Session {session_id} was not found after the run")
|
||||
|
||||
artifacts = await _artifact_payloads(artifact_service, session_id)
|
||||
summary = {
|
||||
"final_answer": _final_text_from_events(session.events),
|
||||
"session_state": session.state,
|
||||
"artifact_names": sorted(artifacts),
|
||||
"artifacts": artifacts,
|
||||
"plugin_events": audit_plugin.events,
|
||||
"event_count": len(session.events),
|
||||
}
|
||||
return {"output": json.dumps(summary, ensure_ascii=False, sort_keys=True)}
|
||||
|
||||
|
||||
async def _run_workflow_provider(
|
||||
prompt: str,
|
||||
options: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
vars_dict = context.get("vars", {})
|
||||
session_id = _session_id(context, vars_dict)
|
||||
session_service = InMemorySessionService()
|
||||
await session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
state={},
|
||||
)
|
||||
|
||||
async with Runner(
|
||||
app=build_workflow_app(_model(options)),
|
||||
session_service=session_service,
|
||||
) as runner:
|
||||
async for _ in runner.run_async(
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
new_message=types.Content(role="user", parts=[types.Part(text=prompt)]),
|
||||
):
|
||||
pass
|
||||
|
||||
session = await session_service.get_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=DEFAULT_USER_ID,
|
||||
session_id=session_id,
|
||||
)
|
||||
if session is None:
|
||||
raise RuntimeError(f"Session {session_id} was not found after the run")
|
||||
|
||||
summary = {
|
||||
"final_answer": _final_text_from_events(session.events),
|
||||
"session_state": session.state,
|
||||
"event_count": len(session.events),
|
||||
}
|
||||
return {"output": json.dumps(summary, ensure_ascii=False, sort_keys=True)}
|
||||
|
||||
|
||||
def call_api(
|
||||
prompt: str, options: dict[str, Any], context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Run the conversational Google ADK example."""
|
||||
|
||||
try:
|
||||
with _provider_span(context, _otlp_endpoint(options)):
|
||||
return asyncio.run(_run_conversational_provider(prompt, options, context))
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "output": f"Error: {exc}"}
|
||||
|
||||
|
||||
def call_workflow_api(
|
||||
prompt: str,
|
||||
options: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Run the SequentialAgent workflow example."""
|
||||
|
||||
try:
|
||||
with _provider_span(context, _otlp_endpoint(options)):
|
||||
return asyncio.run(_run_workflow_provider(prompt, options, context))
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "output": f"Error: {exc}"}
|
||||
Reference in New Issue
Block a user