Files
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

294 lines
9.7 KiB
Python

"""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}"}