graph-agents-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
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
|
+
# https://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
|
+
"""Production guardrails of the chat API, in-process (fastapi runtime, fake model, memory).
|
|
16
|
+
|
|
17
|
+
One run per thread (409), run timeout, recursion limit, heartbeats, client
|
|
18
|
+
disconnect, request and metadata caps, what reaches checkpoints and traces,
|
|
19
|
+
generic errors, thread list/delete, retention, readiness, metrics, request ids
|
|
20
|
+
and CORS.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import uuid
|
|
29
|
+
from collections.abc import AsyncIterator
|
|
30
|
+
from contextlib import aclosing
|
|
31
|
+
from datetime import UTC, datetime, timedelta
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
# The environment must be in place before the app (and the graph) is imported.
|
|
35
|
+
os.environ.update(
|
|
36
|
+
{
|
|
37
|
+
"MODEL_PROVIDER": "fake",
|
|
38
|
+
"MODEL_NAME": "fake",
|
|
39
|
+
"CHECKPOINTER": "memory",
|
|
40
|
+
"AUTH_POLICY": "shared-bearer",
|
|
41
|
+
"API_KEY": "test-key",
|
|
42
|
+
"APP_ENV": "dev",
|
|
43
|
+
"TRACING_ENABLED": "false",
|
|
44
|
+
"RUNTIME": "fastapi",
|
|
45
|
+
"APP_URL": "http://testserver",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
import httpx
|
|
50
|
+
import pytest
|
|
51
|
+
from langchain_core.messages import AIMessageChunk
|
|
52
|
+
|
|
53
|
+
from {{cookiecutter.agent_directory}} import agent as agent_module
|
|
54
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Principal
|
|
55
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME, ChatRequest, sse_encode
|
|
56
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
57
|
+
from {{cookiecutter.agent_directory}}.app_utils.middleware import RunStreamingResponse
|
|
58
|
+
from {{cookiecutter.agent_directory}}.fast_api_app import app
|
|
59
|
+
|
|
60
|
+
AUTH = {"Authorization": "Bearer test-key"}
|
|
61
|
+
SHARED = Principal(id="shared", roles=["shared"])
|
|
62
|
+
LIMIT_VARS = (
|
|
63
|
+
"RUN_TIMEOUT_S",
|
|
64
|
+
"RECURSION_LIMIT",
|
|
65
|
+
"MAX_REQUEST_BYTES",
|
|
66
|
+
"MAX_METADATA_KEYS",
|
|
67
|
+
"MAX_METADATA_VALUE_CHARS",
|
|
68
|
+
"SSE_HEARTBEAT_S",
|
|
69
|
+
"RETENTION_DAYS",
|
|
70
|
+
"TRACE_CAPTURE",
|
|
71
|
+
"METRICS_ENABLED",
|
|
72
|
+
"CORS_ALLOW_ORIGINS",
|
|
73
|
+
"AUTH_READ_ACROSS_ROLES",
|
|
74
|
+
"METRICS_TOKEN",
|
|
75
|
+
"PRINCIPAL_HASH_SALT",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.fixture
|
|
80
|
+
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
|
|
81
|
+
for name in LIMIT_VARS:
|
|
82
|
+
monkeypatch.delenv(name, raising=False)
|
|
83
|
+
async with app.router.lifespan_context(app):
|
|
84
|
+
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
85
|
+
async with httpx.AsyncClient(
|
|
86
|
+
transport=transport, base_url="http://testserver", timeout=30
|
|
87
|
+
) as c:
|
|
88
|
+
yield c
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_sse(text: str) -> list[tuple[str, dict[str, Any]]]:
|
|
92
|
+
events: list[tuple[str, dict[str, Any]]] = []
|
|
93
|
+
event = None
|
|
94
|
+
for line in text.splitlines():
|
|
95
|
+
if line.startswith("event:"):
|
|
96
|
+
event = line[6:].strip()
|
|
97
|
+
elif line.startswith("data:") and event:
|
|
98
|
+
events.append((event, json.loads(line[5:].strip())))
|
|
99
|
+
event = None
|
|
100
|
+
return events
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def chat(
|
|
104
|
+
client: httpx.AsyncClient, message: str, thread_id: str | None = None, **body: Any
|
|
105
|
+
) -> httpx.Response:
|
|
106
|
+
payload: dict[str, Any] = {"message": message, **body}
|
|
107
|
+
if thread_id:
|
|
108
|
+
payload["thread_id"] = thread_id
|
|
109
|
+
return await client.post("/chat", json=payload, headers=AUTH)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def slow_graph(
|
|
113
|
+
monkeypatch: pytest.MonkeyPatch, *, pause: float, started: asyncio.Event | None = None
|
|
114
|
+
):
|
|
115
|
+
"""Make the graph emit one text chunk, then stall for `pause` seconds, then finish."""
|
|
116
|
+
|
|
117
|
+
async def astream(*args: Any, **kwargs: Any):
|
|
118
|
+
if started is not None:
|
|
119
|
+
started.set()
|
|
120
|
+
yield "messages", (AIMessageChunk(content="partial ", id="ai-slow"), {})
|
|
121
|
+
await asyncio.sleep(pause)
|
|
122
|
+
yield "messages", (AIMessageChunk(content="done", id="ai-slow"), {})
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr(agent_module.graph, "astream", astream)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- one run per thread ---------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def test_a_second_run_on_a_busy_thread_gets_409_and_nothing_is_lost(
|
|
131
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
132
|
+
) -> None:
|
|
133
|
+
thread_id = str(uuid.uuid4())
|
|
134
|
+
started = asyncio.Event()
|
|
135
|
+
original = agent_module.graph.astream
|
|
136
|
+
slow_graph(monkeypatch, pause=0.5, started=started)
|
|
137
|
+
first = asyncio.create_task(chat(client, "first", thread_id))
|
|
138
|
+
await started.wait()
|
|
139
|
+
second = await chat(client, "second", thread_id)
|
|
140
|
+
assert second.status_code == 409
|
|
141
|
+
assert second.json() == {
|
|
142
|
+
"code": "thread_busy",
|
|
143
|
+
"detail": "This thread already has a run in progress.",
|
|
144
|
+
}
|
|
145
|
+
assert parse_sse((await first).text)[-1][0] == "message.end"
|
|
146
|
+
monkeypatch.setattr(agent_module.graph, "astream", original)
|
|
147
|
+
# The lock is released with the run: the thread takes the next turn, and
|
|
148
|
+
# every accepted turn is in the history.
|
|
149
|
+
assert parse_sse((await chat(client, "hello again", thread_id)).text)[-1][0] == "message.end"
|
|
150
|
+
messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
|
|
151
|
+
assert [m["content"] for m in messages if m["role"] == "user"] == ["hello again"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def test_concurrent_turns_are_serialised_not_interleaved(client: httpx.AsyncClient) -> None:
|
|
155
|
+
thread_id = str(uuid.uuid4())
|
|
156
|
+
await chat(client, "hello", thread_id)
|
|
157
|
+
responses = await asyncio.gather(*(chat(client, f"turn {i}", thread_id) for i in range(8)))
|
|
158
|
+
accepted = [r for r in responses if r.status_code == 200]
|
|
159
|
+
assert accepted and all(r.status_code in (200, 409) for r in responses)
|
|
160
|
+
messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
|
|
161
|
+
users = [m["content"] for m in messages if m["role"] == "user"]
|
|
162
|
+
# Every turn that got 200 is persisted, each followed by its reply.
|
|
163
|
+
assert len(users) == 1 + len(accepted)
|
|
164
|
+
roles = [m["role"] for m in messages]
|
|
165
|
+
assert roles == ["user", "assistant"] * len(users)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- guardrails --------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def test_run_timeout_cancels_the_run_and_frees_the_thread(
|
|
172
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
173
|
+
) -> None:
|
|
174
|
+
monkeypatch.setenv("RUN_TIMEOUT_S", "0.3")
|
|
175
|
+
slow_graph(monkeypatch, pause=30)
|
|
176
|
+
thread_id = str(uuid.uuid4())
|
|
177
|
+
events = parse_sse((await chat(client, "hello", thread_id)).text)
|
|
178
|
+
assert [e for e, _ in events] == ["message.start", "message.delta", "error"]
|
|
179
|
+
assert events[-1][1]["code"] == "timeout" and "0.3 s" in events[-1][1]["message"]
|
|
180
|
+
timeout = events[-1][1]
|
|
181
|
+
assert timeout["error_id"] in timeout["message"] and timeout["run_id"] == events[0][1]["run_id"]
|
|
182
|
+
assert RUNTIME.runs is not None
|
|
183
|
+
record = await RUNTIME.runs.get(events[0][1]["run_id"])
|
|
184
|
+
assert record is not None and record.status == "timeout"
|
|
185
|
+
assert thread_id not in RUNTIME.locks.held
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def test_a_run_stopped_mid_tool_call_leaves_a_usable_thread(
|
|
189
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, use_test_tools
|
|
190
|
+
) -> None:
|
|
191
|
+
"""The open tool call gets an error result, so the next turn's history is valid."""
|
|
192
|
+
import time
|
|
193
|
+
|
|
194
|
+
from langchain_core.tools import tool
|
|
195
|
+
|
|
196
|
+
@tool
|
|
197
|
+
def slow_probe(query: str) -> str:
|
|
198
|
+
"""Test-only tool that outlives the run timeout."""
|
|
199
|
+
time.sleep(1.0)
|
|
200
|
+
return "late"
|
|
201
|
+
|
|
202
|
+
use_test_tools(slow_probe)
|
|
203
|
+
monkeypatch.setenv("RUN_TIMEOUT_S", "0.4")
|
|
204
|
+
thread_id = str(uuid.uuid4())
|
|
205
|
+
events = parse_sse((await chat(client, "Run the slow probe for Paris", thread_id)).text)
|
|
206
|
+
assert [e for e, _ in events] == ["message.start", "tool.call", "error"]
|
|
207
|
+
messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
|
|
208
|
+
assert [m["role"] for m in messages] == ["user", "assistant", "tool"]
|
|
209
|
+
assert messages[2]["is_error"] is True and "did not finish" in messages[2]["content"]
|
|
210
|
+
assert messages[2]["tool_call_id"] == messages[1]["tool_calls"][0]["id"]
|
|
211
|
+
monkeypatch.delenv("RUN_TIMEOUT_S")
|
|
212
|
+
events = parse_sse((await chat(client, "hello", thread_id)).text)
|
|
213
|
+
assert events[-1][0] == "message.end"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def test_idle_streams_get_heartbeat_comments(
|
|
217
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
218
|
+
) -> None:
|
|
219
|
+
monkeypatch.setenv("SSE_HEARTBEAT_S", "0.05")
|
|
220
|
+
slow_graph(monkeypatch, pause=0.3)
|
|
221
|
+
text = (await chat(client, "hello")).text
|
|
222
|
+
assert ": keep-alive\n\n" in text
|
|
223
|
+
assert parse_sse(text)[-1][0] == "message.end"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _probe_tool() -> Any:
|
|
227
|
+
from langchain_core.tools import tool
|
|
228
|
+
|
|
229
|
+
@tool
|
|
230
|
+
def probe(query: str) -> str:
|
|
231
|
+
"""Test-only tool."""
|
|
232
|
+
return "ok"
|
|
233
|
+
|
|
234
|
+
return probe
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.mark.parametrize("limit", ["1", "2"])
|
|
238
|
+
async def test_recursion_limit_ends_the_run_with_a_reply_and_keeps_its_work(
|
|
239
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, use_test_tools, limit: str
|
|
240
|
+
) -> None:
|
|
241
|
+
"""1: stopped between the tool call and the tool; 2: after the tool, before the answer."""
|
|
242
|
+
monkeypatch.setenv("RECURSION_LIMIT", limit)
|
|
243
|
+
use_test_tools(_probe_tool())
|
|
244
|
+
thread_id = str(uuid.uuid4())
|
|
245
|
+
events = parse_sse((await chat(client, "Run the probe for San Francisco", thread_id)).text)
|
|
246
|
+
names = [e for e, _ in events]
|
|
247
|
+
assert names[:2] == ["message.start", "tool.call"] and names[-2:] == [
|
|
248
|
+
"message.delta",
|
|
249
|
+
"message.end",
|
|
250
|
+
]
|
|
251
|
+
assert "error" not in names
|
|
252
|
+
reply = events[-2][1]["text"]
|
|
253
|
+
assert reply.startswith("I had to stop before finishing") and f"({limit})" in reply
|
|
254
|
+
assert events[-1][1]["status"] == "step_limit"
|
|
255
|
+
assert RUNTIME.runs is not None
|
|
256
|
+
record = await RUNTIME.runs.get(events[0][1]["run_id"])
|
|
257
|
+
assert record is not None and record.status == "step_limit"
|
|
258
|
+
# The thread keeps the call, its result (an error when the tool never ran) and the reply.
|
|
259
|
+
messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
|
|
260
|
+
assert [m["role"] for m in messages] == ["user", "assistant", "tool", "assistant"]
|
|
261
|
+
assert messages[2]["tool_call_id"] == messages[1]["tool_calls"][0]["id"]
|
|
262
|
+
assert messages[2]["is_error"] is (limit == "1")
|
|
263
|
+
assert messages[3]["content"] == reply
|
|
264
|
+
# "continue" works with a fresh step budget.
|
|
265
|
+
monkeypatch.delenv("RECURSION_LIMIT")
|
|
266
|
+
events = parse_sse((await chat(client, "hello", thread_id)).text)
|
|
267
|
+
assert events[-1][0] == "message.end" and events[-1][1]["status"] == "ok"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def test_recursion_limit_is_an_error_when_the_reply_cannot_be_written(
|
|
271
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, use_test_tools
|
|
272
|
+
) -> None:
|
|
273
|
+
monkeypatch.setenv("RECURSION_LIMIT", "1")
|
|
274
|
+
use_test_tools(_probe_tool())
|
|
275
|
+
|
|
276
|
+
async def broken(*args: Any, **kwargs: Any) -> Any:
|
|
277
|
+
raise OSError("database unreachable")
|
|
278
|
+
|
|
279
|
+
monkeypatch.setattr(RUNTIME, "_repair_history", broken)
|
|
280
|
+
monkeypatch.setattr(RUNTIME, "_repair_before_run", lambda *a, **k: asyncio.sleep(0))
|
|
281
|
+
events = parse_sse((await chat(client, "Run the probe for San Francisco")).text)
|
|
282
|
+
assert events[-1][0] == "error"
|
|
283
|
+
assert events[-1][1]["code"] == "recursion_limit" and "(1 steps)" in events[-1][1]["message"]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def test_the_run_uses_the_configured_recursion_limit(
|
|
287
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
288
|
+
) -> None:
|
|
289
|
+
seen: dict[str, Any] = {}
|
|
290
|
+
original = agent_module.graph.astream
|
|
291
|
+
|
|
292
|
+
def capture(*args: Any, **kwargs: Any):
|
|
293
|
+
seen.update(kwargs["config"])
|
|
294
|
+
return original(*args, **kwargs)
|
|
295
|
+
|
|
296
|
+
monkeypatch.setattr(agent_module.graph, "astream", capture)
|
|
297
|
+
await chat(client, "hello")
|
|
298
|
+
assert seen["recursion_limit"] == 50
|
|
299
|
+
assert agent_module.graph.config["recursion_limit"] == 50 # native default too
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def test_a_disconnected_client_cancels_the_run(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
303
|
+
"""The run is cancelled when the client goes away, recorded, and the thread freed."""
|
|
304
|
+
started = asyncio.Event()
|
|
305
|
+
slow_graph(monkeypatch, pause=30, started=started)
|
|
306
|
+
async with app.router.lifespan_context(app):
|
|
307
|
+
req = ChatRequest(message="hello")
|
|
308
|
+
thread_id = await RUNTIME.resolve_thread(SHARED, req)
|
|
309
|
+
lease = await RUNTIME.acquire_thread(thread_id)
|
|
310
|
+
run_ids: list[str] = []
|
|
311
|
+
|
|
312
|
+
async def events() -> AsyncIterator[str]:
|
|
313
|
+
async with aclosing(RUNTIME.stream(SHARED, req, thread_id, lease=lease)) as stream:
|
|
314
|
+
async for event, data in stream:
|
|
315
|
+
if event == "message.start":
|
|
316
|
+
run_ids.append(data["run_id"])
|
|
317
|
+
yield sse_encode(event, data)
|
|
318
|
+
|
|
319
|
+
response = RunStreamingResponse(
|
|
320
|
+
events(), on_close=lease.release, media_type="text/event-stream"
|
|
321
|
+
)
|
|
322
|
+
sent: list[dict[str, Any]] = []
|
|
323
|
+
|
|
324
|
+
async def send(message: dict[str, Any]) -> None:
|
|
325
|
+
sent.append(message)
|
|
326
|
+
|
|
327
|
+
async def receive() -> dict[str, Any]:
|
|
328
|
+
await started.wait()
|
|
329
|
+
await asyncio.sleep(0.05)
|
|
330
|
+
return {"type": "http.disconnect"}
|
|
331
|
+
|
|
332
|
+
scope = {"type": "http", "asgi": {"spec_version": "2.3"}}
|
|
333
|
+
await asyncio.wait_for(response(scope, receive, send), timeout=5)
|
|
334
|
+
assert RUNTIME.runs is not None
|
|
335
|
+
record = await RUNTIME.runs.get(run_ids[0])
|
|
336
|
+
assert record is not None and record.status == "cancelled"
|
|
337
|
+
assert lease.released and thread_id not in RUNTIME.locks.held
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --- request limits ------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def test_request_bodies_over_the_cap_get_413(
|
|
344
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
345
|
+
) -> None:
|
|
346
|
+
monkeypatch.setenv("MAX_REQUEST_BYTES", "2000")
|
|
347
|
+
big = {"message": "x" * 3000}
|
|
348
|
+
r = await client.post("/chat", json=big, headers=AUTH)
|
|
349
|
+
assert r.status_code == 413 and "MAX_REQUEST_BYTES" in r.json()["detail"]
|
|
350
|
+
|
|
351
|
+
async def chunks() -> AsyncIterator[bytes]: # no Content-Length: counted while read
|
|
352
|
+
body = json.dumps(big).encode()
|
|
353
|
+
for i in range(0, len(body), 500):
|
|
354
|
+
yield body[i : i + 500]
|
|
355
|
+
|
|
356
|
+
r = await client.post(
|
|
357
|
+
"/chat", content=chunks(), headers={**AUTH, "content-type": "application/json"}
|
|
358
|
+
)
|
|
359
|
+
assert r.status_code == 413
|
|
360
|
+
# Unauthenticated callers are refused just the same, before any parsing.
|
|
361
|
+
assert (await client.post("/chat", json=big)).status_code == 413
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def test_deeply_nested_json_is_refused_not_a_crash(client: httpx.AsyncClient) -> None:
|
|
365
|
+
body = '{"message": "hi", "metadata": {"a": ' + "[" * 100_000 + "]" * 100_000 + "}}"
|
|
366
|
+
r = await client.post(
|
|
367
|
+
"/chat", content=body.encode(), headers={**AUTH, "content-type": "application/json"}
|
|
368
|
+
)
|
|
369
|
+
assert 400 <= r.status_code < 500
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def test_a2a_style_callers_get_thread_busy_as_an_event(client: httpx.AsyncClient) -> None:
|
|
373
|
+
"""`stream()` without a lease (the A2A path) takes the lock itself."""
|
|
374
|
+
thread_id = await RUNTIME.resolve_thread(SHARED, ChatRequest(message="x"))
|
|
375
|
+
lease = await RUNTIME.acquire_thread(thread_id)
|
|
376
|
+
events = [e async for e in RUNTIME.stream(SHARED, ChatRequest(message="x"), thread_id)]
|
|
377
|
+
assert events == [
|
|
378
|
+
("error", {"code": "thread_busy", "message": "This thread already has a run in progress."})
|
|
379
|
+
]
|
|
380
|
+
await lease.release()
|
|
381
|
+
events = [e async for e in RUNTIME.stream(SHARED, ChatRequest(message="x"), thread_id)]
|
|
382
|
+
assert events[-1][0] == "message.end"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def test_a_database_failure_is_a_generic_503(
|
|
386
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
387
|
+
) -> None:
|
|
388
|
+
assert RUNTIME.threads is not None
|
|
389
|
+
|
|
390
|
+
async def down(*args: Any, **kwargs: Any) -> Any:
|
|
391
|
+
raise OSError("connection to server at 10.0.0.7, port 5432 failed")
|
|
392
|
+
|
|
393
|
+
monkeypatch.setattr(RUNTIME.threads, "ensure", down)
|
|
394
|
+
r = await chat(client, "hello")
|
|
395
|
+
assert r.status_code == 503 and "10.0.0.7" not in r.text
|
|
396
|
+
assert r.json()["detail"].startswith("Database unavailable. Reference: ")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@pytest.mark.parametrize("value", ["NaN", "Infinity", "-Infinity"])
|
|
400
|
+
async def test_non_finite_numbers_get_422_not_500(client: httpx.AsyncClient, value: str) -> None:
|
|
401
|
+
"""Python's JSON parser accepts NaN/Infinity; the 422 that echoes them must stay valid JSON."""
|
|
402
|
+
for body in (
|
|
403
|
+
'{"message": "hi", "metadata": {"k": VALUE}}',
|
|
404
|
+
'{"message": VALUE}',
|
|
405
|
+
'{"message": "hi", "metadata": {"k": [VALUE]}}',
|
|
406
|
+
):
|
|
407
|
+
r = await client.post(
|
|
408
|
+
"/chat",
|
|
409
|
+
content=body.replace("VALUE", value).encode(),
|
|
410
|
+
headers={**AUTH, "content-type": "application/json"},
|
|
411
|
+
)
|
|
412
|
+
assert r.status_code == 422, r.text
|
|
413
|
+
|
|
414
|
+
def refuse(constant: str) -> None:
|
|
415
|
+
raise AssertionError(f"non-standard JSON constant {constant} in the response")
|
|
416
|
+
|
|
417
|
+
detail = json.loads(r.text, parse_constant=refuse)["detail"]
|
|
418
|
+
assert detail and all({"type", "loc", "msg"} <= set(error) for error in detail)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@pytest.mark.parametrize(
|
|
422
|
+
"metadata",
|
|
423
|
+
[
|
|
424
|
+
{f"k{i}": i for i in range(17)},
|
|
425
|
+
{"k": "v" * 257},
|
|
426
|
+
{"k" * 257: "v"},
|
|
427
|
+
{"nested": {"a": 1}},
|
|
428
|
+
{"list": [1, 2]},
|
|
429
|
+
],
|
|
430
|
+
)
|
|
431
|
+
async def test_metadata_outside_the_caps_gets_422(
|
|
432
|
+
client: httpx.AsyncClient, metadata: dict[str, Any]
|
|
433
|
+
) -> None:
|
|
434
|
+
r = await chat(client, "hello", metadata=metadata)
|
|
435
|
+
assert r.status_code == 422, r.text
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
async def test_metadata_at_the_caps_is_accepted(client: httpx.AsyncClient) -> None:
|
|
439
|
+
metadata = {f"k{i}": "v" * 256 for i in range(15)} | {"n": None}
|
|
440
|
+
assert (await chat(client, "hello", metadata=metadata)).status_code == 200
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@pytest.mark.parametrize("thread_id", ["bad id", "a/b", "x" * 129, "ümlaut"])
|
|
444
|
+
async def test_invalid_thread_ids_get_422_everywhere(
|
|
445
|
+
client: httpx.AsyncClient, thread_id: str
|
|
446
|
+
) -> None:
|
|
447
|
+
assert (await chat(client, "hello", thread_id)).status_code == 422
|
|
448
|
+
quoted = httpx.URL(f"/threads/{thread_id.replace('/', '%2F')}/messages")
|
|
449
|
+
r = await client.get(quoted, headers=AUTH)
|
|
450
|
+
assert r.status_code in (404, 422) # an unroutable path never reaches a store
|
|
451
|
+
with pytest.raises(Exception) as exc:
|
|
452
|
+
await RUNTIME.resolve_thread(SHARED, ChatRequest(message="x", thread_id=thread_id))
|
|
453
|
+
assert getattr(exc.value, "status_code", None) == 422
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# --- what is persisted and traced --------------------------------------------------
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@pytest.mark.parametrize("capture", ["metadata", "full"])
|
|
460
|
+
async def test_client_metadata_stays_out_of_checkpoints_and_cannot_spoof_ids(
|
|
461
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, capture: str
|
|
462
|
+
) -> None:
|
|
463
|
+
monkeypatch.setenv("TRACE_CAPTURE", capture)
|
|
464
|
+
seen: dict[str, Any] = {}
|
|
465
|
+
original = agent_module.graph.astream
|
|
466
|
+
|
|
467
|
+
def capture_config(*args: Any, **kwargs: Any):
|
|
468
|
+
seen.update(kwargs["config"])
|
|
469
|
+
return original(*args, **kwargs)
|
|
470
|
+
|
|
471
|
+
monkeypatch.setattr(agent_module.graph, "astream", capture_config)
|
|
472
|
+
thread_id = str(uuid.uuid4())
|
|
473
|
+
client_meta = {
|
|
474
|
+
"thread_id": "SPOOFED",
|
|
475
|
+
"run_id": "SPOOFED",
|
|
476
|
+
"principal_hash": "SPOOFED",
|
|
477
|
+
"email": "pii@example.com",
|
|
478
|
+
}
|
|
479
|
+
events = parse_sse((await chat(client, "hello", thread_id, metadata=client_meta)).text)
|
|
480
|
+
run_id = events[0][1]["run_id"]
|
|
481
|
+
trace_meta = seen["metadata"]
|
|
482
|
+
assert trace_meta["thread_id"] == thread_id and trace_meta["run_id"] == run_id
|
|
483
|
+
assert trace_meta["principal_hash"] == SHARED.hashed_id()
|
|
484
|
+
if capture == "full":
|
|
485
|
+
assert trace_meta["client_metadata"] == client_meta
|
|
486
|
+
else:
|
|
487
|
+
assert "client_metadata" not in trace_meta and "pii@example.com" not in str(trace_meta)
|
|
488
|
+
# No checkpoint of the thread carries the client's metadata.
|
|
489
|
+
checkpoints = [
|
|
490
|
+
c
|
|
491
|
+
async for c in agent_module.graph.checkpointer.alist(
|
|
492
|
+
{"configurable": {"thread_id": thread_id}}
|
|
493
|
+
)
|
|
494
|
+
]
|
|
495
|
+
assert checkpoints
|
|
496
|
+
assert all("pii@example.com" not in str(c.metadata) for c in checkpoints)
|
|
497
|
+
assert all(c.metadata.get("run_id") == run_id for c in checkpoints)
|
|
498
|
+
# The run record keeps it.
|
|
499
|
+
assert RUNTIME.runs is not None
|
|
500
|
+
record = await RUNTIME.runs.get(run_id)
|
|
501
|
+
assert record is not None and record.metadata == client_meta
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# --- threads API and retention -----------------------------------------------------
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
async def test_threads_can_be_listed_and_deleted_by_their_owner(client: httpx.AsyncClient) -> None:
|
|
508
|
+
first, second = str(uuid.uuid4()), str(uuid.uuid4())
|
|
509
|
+
await chat(client, "hello", first)
|
|
510
|
+
await chat(client, "hello", second)
|
|
511
|
+
listed = (await client.get("/threads", headers=AUTH)).json()
|
|
512
|
+
ids = [t["thread_id"] for t in listed]
|
|
513
|
+
assert ids.index(second) < ids.index(first) # most recently active first
|
|
514
|
+
assert set(listed[0]) == {"thread_id", "owner", "created_at", "updated_at"}
|
|
515
|
+
assert (await client.get("/threads?limit=1", headers=AUTH)).json()[0]["thread_id"] == second
|
|
516
|
+
assert (await client.get("/threads?limit=0", headers=AUTH)).status_code == 422
|
|
517
|
+
|
|
518
|
+
r = await client.delete(f"/threads/{first}", headers=AUTH)
|
|
519
|
+
assert r.status_code == 204
|
|
520
|
+
assert (await client.get(f"/threads/{first}/messages", headers=AUTH)).status_code == 404
|
|
521
|
+
assert first not in [
|
|
522
|
+
t["thread_id"] for t in (await client.get("/threads", headers=AUTH)).json()
|
|
523
|
+
]
|
|
524
|
+
state = await agent_module.graph.aget_state({"configurable": {"thread_id": first}})
|
|
525
|
+
assert not state.values # checkpoints gone
|
|
526
|
+
assert RUNTIME.runs is not None and await RUNTIME.runs.list_for_thread(first) == []
|
|
527
|
+
assert (await client.delete(f"/threads/{first}", headers=AUTH)).status_code == 404
|
|
528
|
+
assert (await client.delete("/threads/bad%20id", headers=AUTH)).status_code == 422
|
|
529
|
+
assert (await client.delete(f"/threads/{second}")).status_code == 401
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
async def test_only_the_owner_deletes_and_never_during_a_run(
|
|
533
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
534
|
+
) -> None:
|
|
535
|
+
thread_id = str(uuid.uuid4())
|
|
536
|
+
await chat(client, "hello", thread_id)
|
|
537
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
|
|
538
|
+
for other in (Principal(id="stranger"), Principal(id="auditor", roles=["auditor"])):
|
|
539
|
+
with pytest.raises(Exception) as exc:
|
|
540
|
+
await RUNTIME.delete_thread(other, thread_id)
|
|
541
|
+
assert getattr(exc.value, "status_code", None) == 403
|
|
542
|
+
lease = await RUNTIME.acquire_thread(thread_id)
|
|
543
|
+
r = await client.delete(f"/threads/{thread_id}", headers=AUTH)
|
|
544
|
+
assert r.status_code == 409 and r.json()["code"] == "thread_busy"
|
|
545
|
+
await lease.release()
|
|
546
|
+
assert (await client.delete(f"/threads/{thread_id}", headers=AUTH)).status_code == 204
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
async def test_a_delete_racing_a_chat_leaves_no_ownerless_state(
|
|
550
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
551
|
+
) -> None:
|
|
552
|
+
"""A DELETE that lands after /chat checked ownership, before it took the run lock.
|
|
553
|
+
|
|
554
|
+
The turn must not be written to a thread without an owner row, where another
|
|
555
|
+
principal could claim the id and read it.
|
|
556
|
+
"""
|
|
557
|
+
thread_id = str(uuid.uuid4())
|
|
558
|
+
await chat(client, "first", thread_id)
|
|
559
|
+
resolve = RUNTIME.resolve_thread
|
|
560
|
+
|
|
561
|
+
async def resolve_then_delete(principal: Principal, req: ChatRequest) -> str:
|
|
562
|
+
resolved = await resolve(principal, req)
|
|
563
|
+
if req.message == "secret second turn":
|
|
564
|
+
await RUNTIME.delete_thread(principal, resolved) # the racing DELETE
|
|
565
|
+
return resolved
|
|
566
|
+
|
|
567
|
+
monkeypatch.setattr(RUNTIME, "resolve_thread", resolve_then_delete)
|
|
568
|
+
events = parse_sse((await chat(client, "secret second turn", thread_id)).text)
|
|
569
|
+
assert events[-1][0] == "message.end"
|
|
570
|
+
assert RUNTIME.threads is not None
|
|
571
|
+
record = await RUNTIME.threads.get(thread_id)
|
|
572
|
+
# The turn started the thread afresh under its sender, never ownerless.
|
|
573
|
+
assert record is not None and record.principal_id == "shared"
|
|
574
|
+
bob = Principal(id="bob")
|
|
575
|
+
with pytest.raises(Exception) as exc:
|
|
576
|
+
await RUNTIME.resolve_thread(bob, ChatRequest(message="mine now", thread_id=thread_id))
|
|
577
|
+
assert getattr(exc.value, "status_code", None) == 403
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
async def test_a_delete_rechecks_the_owner_under_the_lock(
|
|
581
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
582
|
+
) -> None:
|
|
583
|
+
thread_id = str(uuid.uuid4())
|
|
584
|
+
await chat(client, "hello", thread_id)
|
|
585
|
+
acquire = RUNTIME.acquire_thread
|
|
586
|
+
assert RUNTIME.threads is not None
|
|
587
|
+
|
|
588
|
+
async def acquire_after_a_reclaim(tid: str, principal: Principal | None = None) -> Any:
|
|
589
|
+
# Between the owner check and the lock, the thread goes and bob claims the id.
|
|
590
|
+
await RUNTIME._delete_thread_data(tid)
|
|
591
|
+
await RUNTIME.threads.claim(tid, Principal(id="bob"))
|
|
592
|
+
return await acquire(tid, principal)
|
|
593
|
+
|
|
594
|
+
monkeypatch.setattr(RUNTIME, "acquire_thread", acquire_after_a_reclaim)
|
|
595
|
+
assert (await client.delete(f"/threads/{thread_id}", headers=AUTH)).status_code == 403
|
|
596
|
+
record = await RUNTIME.threads.get(thread_id)
|
|
597
|
+
assert record is not None and record.principal_id == "bob"
|
|
598
|
+
assert thread_id not in RUNTIME.locks.held
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
async def test_a2a_style_runs_recheck_ownership_under_the_lock(client: httpx.AsyncClient) -> None:
|
|
602
|
+
"""`stream()` taking the lock itself re-checks the owner too (the A2A path)."""
|
|
603
|
+
thread_id = str(uuid.uuid4())
|
|
604
|
+
await chat(client, "first", thread_id)
|
|
605
|
+
req = ChatRequest(message="second", thread_id=thread_id)
|
|
606
|
+
resolved = await RUNTIME.resolve_thread(SHARED, req)
|
|
607
|
+
assert RUNTIME.threads is not None
|
|
608
|
+
await RUNTIME.delete_thread(SHARED, resolved)
|
|
609
|
+
# Someone else claims the id in between: the run is refused, nothing is written.
|
|
610
|
+
await RUNTIME.threads.claim(thread_id, Principal(id="bob"))
|
|
611
|
+
events = [e async for e in RUNTIME.stream(SHARED, req, resolved)]
|
|
612
|
+
assert events == [
|
|
613
|
+
("error", {"code": "forbidden", "message": "This thread belongs to another principal."})
|
|
614
|
+
]
|
|
615
|
+
state = await agent_module.graph.aget_state({"configurable": {"thread_id": thread_id}})
|
|
616
|
+
assert not state.values
|
|
617
|
+
assert thread_id not in RUNTIME.locks.held
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
async def test_a_thread_id_with_state_but_no_owner_cannot_be_claimed(
|
|
621
|
+
client: httpx.AsyncClient,
|
|
622
|
+
) -> None:
|
|
623
|
+
"""Checkpoints without an owner row (e.g. left by an older version) never pass to a new owner."""
|
|
624
|
+
thread_id = str(uuid.uuid4())
|
|
625
|
+
await agent_module.graph.ainvoke(
|
|
626
|
+
{"messages": [{"role": "user", "content": "orphaned secret"}]},
|
|
627
|
+
{"configurable": {"thread_id": thread_id}},
|
|
628
|
+
)
|
|
629
|
+
r = await chat(client, "let me read that", thread_id)
|
|
630
|
+
assert r.status_code == 403
|
|
631
|
+
assert RUNTIME.threads is not None and await RUNTIME.threads.get(thread_id) is None
|
|
632
|
+
assert (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).status_code == 404
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
async def test_retention_rechecks_idleness_under_the_lock(
|
|
636
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
637
|
+
) -> None:
|
|
638
|
+
"""A thread resumed after the purge listed it (and before it was locked) is kept."""
|
|
639
|
+
thread_id = str(uuid.uuid4())
|
|
640
|
+
await chat(client, "hello", thread_id)
|
|
641
|
+
assert RUNTIME.threads is not None
|
|
642
|
+
RUNTIME.threads._memory[thread_id].updated_at = (
|
|
643
|
+
datetime.now(tz=UTC) - timedelta(days=40)
|
|
644
|
+
).isoformat()
|
|
645
|
+
idle_before = RUNTIME.threads.idle_before
|
|
646
|
+
|
|
647
|
+
async def list_then_resume(cutoff_iso: str, *, limit: int = 500) -> list[str]:
|
|
648
|
+
candidates = await idle_before(cutoff_iso, limit=limit)
|
|
649
|
+
assert thread_id in candidates
|
|
650
|
+
await chat(client, "resumed", thread_id) # the owner comes back mid-round
|
|
651
|
+
return candidates
|
|
652
|
+
|
|
653
|
+
monkeypatch.setattr(RUNTIME.threads, "idle_before", list_then_resume)
|
|
654
|
+
assert await RUNTIME.purge_expired(30) == 0
|
|
655
|
+
assert await RUNTIME.threads.get(thread_id) is not None
|
|
656
|
+
messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
|
|
657
|
+
assert [m["content"] for m in messages if m["role"] == "user"] == ["hello", "resumed"]
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
async def test_retention_purges_idle_threads_only(
|
|
661
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
662
|
+
) -> None:
|
|
663
|
+
idle, busy, fresh = (str(uuid.uuid4()) for _ in range(3))
|
|
664
|
+
for thread_id in (idle, busy, fresh):
|
|
665
|
+
await chat(client, "hello", thread_id)
|
|
666
|
+
assert RUNTIME.threads is not None
|
|
667
|
+
old = (datetime.now(tz=UTC) - timedelta(days=40)).isoformat()
|
|
668
|
+
RUNTIME.threads._memory[idle].updated_at = old
|
|
669
|
+
RUNTIME.threads._memory[busy].updated_at = old
|
|
670
|
+
assert await RUNTIME.purge_expired(0) == 0 # 0 keeps everything
|
|
671
|
+
lease = await RUNTIME.acquire_thread(busy)
|
|
672
|
+
assert await RUNTIME.purge_expired(30) == 1
|
|
673
|
+
await lease.release()
|
|
674
|
+
assert await RUNTIME.threads.get(idle) is None
|
|
675
|
+
assert await RUNTIME.threads.get(busy) is not None and await RUNTIME.threads.get(fresh)
|
|
676
|
+
state = await agent_module.graph.aget_state({"configurable": {"thread_id": idle}})
|
|
677
|
+
assert not state.values
|
|
678
|
+
# A continued thread is active again: its idle clock restarts.
|
|
679
|
+
RUNTIME.threads._memory[fresh].updated_at = old
|
|
680
|
+
await chat(client, "again", fresh)
|
|
681
|
+
assert await RUNTIME.purge_expired(30) == 1 # only `busy`, now idle and free
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# --- errors, readiness, metrics, request ids, CORS ----------------------------------
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
async def test_unhandled_errors_answer_a_generic_500_with_an_error_id(
|
|
688
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
689
|
+
) -> None:
|
|
690
|
+
async def boom(*args: Any, **kwargs: Any) -> Any:
|
|
691
|
+
raise RuntimeError("connection to 10.0.0.7:5432 refused")
|
|
692
|
+
|
|
693
|
+
monkeypatch.setattr(RUNTIME, "list_threads", boom)
|
|
694
|
+
r = await client.get("/threads", headers={**AUTH, "X-Request-ID": "req-500"})
|
|
695
|
+
assert r.status_code == 500
|
|
696
|
+
body = r.json()
|
|
697
|
+
assert "10.0.0.7" not in r.text and body["error_id"] in body["detail"]
|
|
698
|
+
# The 500 comes from outside the request-id middleware; it still names the request.
|
|
699
|
+
assert r.headers["x-request-id"] == "req-500"
|
|
700
|
+
r = await client.get("/threads", headers=AUTH)
|
|
701
|
+
assert r.status_code == 500 and len(r.headers["x-request-id"]) == 32
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
async def test_ready_reflects_the_database(
|
|
705
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
706
|
+
) -> None:
|
|
707
|
+
r = await client.get("/ready")
|
|
708
|
+
assert r.status_code == 200 and r.json() == {"status": "ready"}
|
|
709
|
+
assert RUNTIME.db is not None
|
|
710
|
+
|
|
711
|
+
async def down() -> None:
|
|
712
|
+
raise OSError("database unreachable")
|
|
713
|
+
|
|
714
|
+
monkeypatch.setattr(RUNTIME.db, "ping", down)
|
|
715
|
+
r = await client.get("/ready")
|
|
716
|
+
assert r.status_code == 503 and r.json() == {"status": "not_ready"}
|
|
717
|
+
assert (await client.get("/health")).status_code == 200 # liveness is process-only
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
async def test_metrics_count_requests_and_runs(
|
|
721
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
722
|
+
) -> None:
|
|
723
|
+
await chat(client, "hello")
|
|
724
|
+
await client.get(f"/threads/{uuid.uuid4()}/messages", headers=AUTH)
|
|
725
|
+
r = await client.get("/metrics")
|
|
726
|
+
assert r.status_code == 200 and r.headers["content-type"].startswith("text/plain")
|
|
727
|
+
text = r.text
|
|
728
|
+
assert 'agent_runs_total{status="ok"}' in text
|
|
729
|
+
assert 'http_requests_total{method="POST",route="/chat",status="200"}' in text
|
|
730
|
+
# Route templates, never raw paths (no series per thread id).
|
|
731
|
+
assert 'route="/threads/{thread_id}/messages"' in text
|
|
732
|
+
assert "agent_active_runs" in text and "agent_tokens_total" in text
|
|
733
|
+
monkeypatch.setenv("METRICS_ENABLED", "false")
|
|
734
|
+
assert (await client.get("/metrics")).status_code == 404
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
async def test_metrics_token_protects_metrics_only(
|
|
738
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
739
|
+
) -> None:
|
|
740
|
+
monkeypatch.setenv("METRICS_TOKEN", "scrape-secret-0123456789")
|
|
741
|
+
for headers in (
|
|
742
|
+
{},
|
|
743
|
+
{"Authorization": "Bearer wrong"},
|
|
744
|
+
{"Authorization": "Basic scrape-secret-0123456789"},
|
|
745
|
+
AUTH, # the API key is not the metrics token
|
|
746
|
+
):
|
|
747
|
+
r = await client.get("/metrics", headers=headers)
|
|
748
|
+
assert r.status_code == 401 and r.headers["www-authenticate"] == "Bearer"
|
|
749
|
+
assert "agent_runs_total" not in r.text
|
|
750
|
+
r = await client.get("/metrics", headers={"Authorization": "Bearer scrape-secret-0123456789"})
|
|
751
|
+
assert r.status_code == 200 and "http_requests_total" in r.text
|
|
752
|
+
# Probes stay open.
|
|
753
|
+
assert (await client.get("/health")).status_code == 200
|
|
754
|
+
assert (await client.get("/ready")).status_code == 200
|
|
755
|
+
monkeypatch.setenv("METRICS_TOKEN", " ") # blank = not set
|
|
756
|
+
assert (await client.get("/metrics")).status_code == 200
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
async def test_request_ids_are_echoed_or_generated(client: httpx.AsyncClient) -> None:
|
|
760
|
+
r = await client.get("/health", headers={"X-Request-ID": "req-123.abc"})
|
|
761
|
+
assert r.headers["x-request-id"] == "req-123.abc"
|
|
762
|
+
r = await client.get("/health", headers={"X-Request-ID": "bad id\r\nx"[:6]})
|
|
763
|
+
assert r.headers["x-request-id"] != "bad id" and len(r.headers["x-request-id"]) == 32
|
|
764
|
+
r = await client.get("/health")
|
|
765
|
+
assert len(r.headers["x-request-id"]) == 32
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
async def test_bad_settings_stop_startup(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
769
|
+
bad = {
|
|
770
|
+
"RUN_TIMEOUT_S": "five minutes",
|
|
771
|
+
"MAX_METADATA_KEYS": "-1",
|
|
772
|
+
"TRACE_CAPTURE": "everything",
|
|
773
|
+
"A2A_TASK_TTL_S": "an hour",
|
|
774
|
+
}
|
|
775
|
+
for name, value in bad.items():
|
|
776
|
+
monkeypatch.setenv(name, value)
|
|
777
|
+
with pytest.raises(SettingsError) as exc:
|
|
778
|
+
async with app.router.lifespan_context(app):
|
|
779
|
+
pass
|
|
780
|
+
for name in bad:
|
|
781
|
+
assert name in str(exc.value)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
async def test_cors_is_off_by_default_and_follows_cors_allow_origins(
|
|
785
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
786
|
+
) -> None:
|
|
787
|
+
import importlib
|
|
788
|
+
|
|
789
|
+
from {{cookiecutter.agent_directory}} import fast_api_app as module
|
|
790
|
+
|
|
791
|
+
preflight = {
|
|
792
|
+
"Origin": "https://app.example.com",
|
|
793
|
+
"Access-Control-Request-Method": "POST",
|
|
794
|
+
"Access-Control-Request-Headers": "authorization,content-type",
|
|
795
|
+
}
|
|
796
|
+
async with httpx.AsyncClient(
|
|
797
|
+
transport=httpx.ASGITransport(app=module.app), base_url="http://testserver"
|
|
798
|
+
) as c:
|
|
799
|
+
r = await c.options("/chat", headers=preflight)
|
|
800
|
+
assert "access-control-allow-origin" not in r.headers
|
|
801
|
+
monkeypatch.setenv("CORS_ALLOW_ORIGINS", "https://app.example.com")
|
|
802
|
+
try:
|
|
803
|
+
cors_app = importlib.reload(module).app
|
|
804
|
+
async with httpx.AsyncClient(
|
|
805
|
+
transport=httpx.ASGITransport(app=cors_app), base_url="http://testserver"
|
|
806
|
+
) as c:
|
|
807
|
+
r = await c.options("/chat", headers=preflight)
|
|
808
|
+
assert r.status_code == 200
|
|
809
|
+
assert r.headers["access-control-allow-origin"] == "https://app.example.com"
|
|
810
|
+
assert r.headers["access-control-allow-credentials"] == "true"
|
|
811
|
+
r = await c.options("/chat", headers={**preflight, "Origin": "https://evil.example"})
|
|
812
|
+
assert "access-control-allow-origin" not in r.headers
|
|
813
|
+
finally:
|
|
814
|
+
monkeypatch.delenv("CORS_ALLOW_ORIGINS")
|
|
815
|
+
importlib.reload(module)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@pytest.mark.parametrize("kind", ["pool_timeout", "operational", "not_ready"])
|
|
819
|
+
async def test_an_unreachable_database_on_any_route_is_a_503_with_one_warning(
|
|
820
|
+
client: httpx.AsyncClient, caplog: pytest.LogCaptureFixture, kind: str
|
|
821
|
+
) -> None:
|
|
822
|
+
"""Routes outside the chat runtime too: 503 with an error id, one WARNING, no traceback."""
|
|
823
|
+
import logging
|
|
824
|
+
|
|
825
|
+
from psycopg import OperationalError
|
|
826
|
+
from psycopg_pool import PoolTimeout
|
|
827
|
+
|
|
828
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import StorageNotReady
|
|
829
|
+
|
|
830
|
+
error = {
|
|
831
|
+
"pool_timeout": PoolTimeout("couldn't get a connection after 5.00 sec"),
|
|
832
|
+
"operational": OperationalError("connection failed: Connection refused"),
|
|
833
|
+
"not_ready": StorageNotReady("the database is not set up yet"),
|
|
834
|
+
}[kind]
|
|
835
|
+
|
|
836
|
+
async def database_down() -> None:
|
|
837
|
+
raise error
|
|
838
|
+
|
|
839
|
+
path = "/_test_database_down"
|
|
840
|
+
app.router.add_api_route(path, database_down, methods=["GET"])
|
|
841
|
+
try:
|
|
842
|
+
with caplog.at_level(logging.INFO):
|
|
843
|
+
r = await client.get(path, headers=AUTH)
|
|
844
|
+
finally:
|
|
845
|
+
app.router.routes[:] = [
|
|
846
|
+
route for route in app.router.routes if getattr(route, "path", None) != path
|
|
847
|
+
]
|
|
848
|
+
assert r.status_code == 503
|
|
849
|
+
body = r.json()
|
|
850
|
+
assert body["detail"] == f"Database unavailable. Reference: {body['error_id']}."
|
|
851
|
+
assert r.headers.get("x-request-id")
|
|
852
|
+
lines = [rec for rec in caplog.records if body["error_id"] in rec.getMessage()]
|
|
853
|
+
assert len(lines) == 1 and lines[0].levelno == logging.WARNING and lines[0].exc_info is None
|
|
854
|
+
assert not [rec for rec in caplog.records if rec.levelno >= logging.ERROR]
|