diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80b996d..3f6687f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,8 +35,9 @@ jobs: forbidden = { "alembic", "asyncpg", "azure-identity", "azure-keyvault-secrets", "boto3", "botocore", "email-validator", - "google-cloud-secret-manager", "redis", "reportlab", - "shim-enterprise", "sqlalchemy", "supabase", + "google-cloud-secret-manager", "polar-sdk", "redis", "reportlab", + "shim-cloud", "shim-enterprise", "sqlalchemy", "standardwebhooks", + "supabase", } assert forbidden.isdisjoint(installed), sorted(forbidden & installed) direct = { @@ -49,8 +50,9 @@ jobs: forbidden_direct = { "alembic", "asyncpg", "azure-identity", "azure-keyvault-secrets", "boto3", "botocore", "cryptography", "email-validator", - "google-cloud-secret-manager", "pyyaml", "redis", "reportlab", - "sqlalchemy", "supabase", + "google-cloud-secret-manager", "polar-sdk", "pyyaml", "redis", + "reportlab", "shim-cloud", "sqlalchemy", "standardwebhooks", + "supabase", } assert forbidden_direct.isdisjoint(direct), sorted(forbidden_direct & direct) ' @@ -115,13 +117,14 @@ jobs: assert not any( component in path.parts for path in paths - for component in ("app", "ee", "shim_enterprise") + for component in ("app", "ee", "shim_cloud", "shim_enterprise") ) forbidden_direct = { "alembic", "asyncpg", "azure-identity", "azure-keyvault-secrets", "boto3", "botocore", "cryptography", "email-validator", - "google-cloud-secret-manager", "pyyaml", "redis", "reportlab", - "sqlalchemy", "supabase", + "google-cloud-secret-manager", "polar-sdk", "pyyaml", "redis", + "reportlab", "shim-cloud", "sqlalchemy", "standardwebhooks", + "supabase", } direct = { requirement.split(";", 1)[0] @@ -146,7 +149,9 @@ jobs: "$RUNNER_TEMP/community-venv/bin/python" -c ' from importlib.util import find_spec import shim.application - assert find_spec("shim_enterprise") is None + assert all(find_spec(module) is None for module in ( + "polar_sdk", "shim_cloud", "shim_enterprise", "standardwebhooks", + )) ' - name: Build community image from scratch run: docker build --pull --no-cache --tag shim-community:ci . @@ -160,9 +165,9 @@ jobs: forbidden = { "alembic", "asyncpg", "azure-identity", "azure-keyvault-secrets", "boto3", "botocore", "email-validator", - "google-cloud-secret-manager", "pytest", "pytest-asyncio", "redis", - "reportlab", "ruff", "shim-enterprise", "sqlalchemy", "supabase", - "ty", + "google-cloud-secret-manager", "polar-sdk", "pytest", "pytest-asyncio", + "redis", "reportlab", "ruff", "shim-cloud", "shim-enterprise", + "sqlalchemy", "standardwebhooks", "supabase", "ty", } assert "shim-gateway" in installed assert forbidden.isdisjoint(installed), sorted(forbidden & installed) @@ -176,8 +181,9 @@ jobs: forbidden_direct = { "alembic", "asyncpg", "azure-identity", "azure-keyvault-secrets", "boto3", "botocore", "cryptography", "email-validator", - "google-cloud-secret-manager", "pyyaml", "redis", "reportlab", - "sqlalchemy", "supabase", + "google-cloud-secret-manager", "polar-sdk", "pyyaml", "redis", + "reportlab", "shim-cloud", "sqlalchemy", "standardwebhooks", + "supabase", } assert forbidden_direct.isdisjoint(direct), sorted(forbidden_direct & direct) package = distribution("shim-gateway") @@ -185,7 +191,9 @@ jobs: assert package.metadata.get_all("License-File") == ["LICENSE", "NOTICE"] assert {"LICENSE", "NOTICE"} <= {Path(path).name for path in package.files or ()} assert find_spec("shim") is not None - assert find_spec("shim_enterprise") is None + assert all(find_spec(module) is None for module in ( + "polar_sdk", "shim_cloud", "shim_enterprise", "standardwebhooks", + )) assert not any(Path(path).exists() for path in ("/app/app", "/app/ee", "/app/alembic")) ' - name: Smoke community image @@ -232,6 +240,11 @@ jobs: ENCRYPTION_KEY: 1yuHjGCrKdLoXrHt6qVL4vd6GHUZ1KVDbsXWJsbq3Kw= SUPABASE_URL: https://example.supabase.co SUPABASE_KEY: ci-public-anon-key + POLAR_ACCESS_TOKEN: ci-only-polar-token + POLAR_WEBHOOK_SECRET: ci-only-polar-webhook-secret + POLAR_ORGANIZATION_ID: 00000000-0000-0000-0000-000000000001 + POLAR_PRODUCTS: '{"managed:monthly":"00000000-0000-0000-0000-000000000011","managed:yearly":"00000000-0000-0000-0000-000000000012","agency:monthly":"00000000-0000-0000-0000-000000000013","agency:yearly":"00000000-0000-0000-0000-000000000014"}' + CLOUD_DASHBOARD_URL: https://cloud.example.test steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -252,15 +265,19 @@ jobs: - run: uv sync --locked --all-packages - run: uv run --locked --package shim-enterprise alembic -c ee/alembic.ini upgrade head - run: uv run --locked --package shim-enterprise alembic -c ee/alembic.ini check - - run: uv run --locked --all-packages ruff format --check src ee/src tests ee/tests scripts ee/scripts ee/alembic - - run: uv run --locked --all-packages ruff check src ee/src tests ee/tests scripts ee/scripts ee/alembic + - run: uv run --locked --package shim-cloud python -m shim_cloud.migrate + - run: uv run --locked --package shim-cloud alembic -c ee/cloud/alembic.ini check + - run: uv run --locked --all-packages ruff format --check src ee/src ee/cloud/src tests ee/tests ee/cloud/tests scripts ee/scripts ee/alembic ee/cloud/alembic + - run: uv run --locked --all-packages ruff check src ee/src ee/cloud/src tests ee/tests ee/cloud/tests scripts ee/scripts ee/alembic ee/cloud/alembic - run: uv run --locked --all-packages ty check - run: uv run --locked --all-packages python -m pytest -q - run: uv run --locked --package shim-enterprise python scripts/export_openapi.py --profile enterprise --check - - name: Build both packages from the validated lock + - run: uv run --locked --package shim-cloud python scripts/export_openapi.py --profile cloud --check + - name: Build all packages from the validated lock run: | uv build --package shim-gateway --wheel --sdist --out-dir dist/community uv build --package shim-enterprise --wheel --sdist --out-dir dist/enterprise + uv build --package shim-cloud --wheel --sdist --out-dir dist/cloud - name: Verify enterprise artifacts run: | python - <<'PY' @@ -316,7 +333,7 @@ jobs: assert not any( component in path.parts for path in paths - for component in ("app", "shim") + for component in ("app", "shim", "shim_cloud") ) assert { "ai_act.yaml", "gdpr.yaml", "iso27001.yaml", "kvkk.yaml", @@ -326,6 +343,65 @@ jobs: } assert "shim-gateway==0.1.3" in metadata.get_all("Requires-Dist", []) PY + - name: Verify cloud artifacts + run: | + python - <<'PY' + from email import message_from_bytes + from pathlib import Path, PurePosixPath + import tarfile + from zipfile import ZipFile + + expected_legal = { + name: (Path("ee") / "cloud" / name).read_bytes() + for name in ("LICENSE", "NOTICE") + } + wheel = next(Path("dist/cloud").glob("*.whl")) + sdist = next(Path("dist/cloud").glob("*.tar.gz")) + with ZipFile(wheel) as archive: + wheel_paths = tuple(PurePosixPath(name) for name in archive.namelist()) + metadata_path = next( + name for name in archive.namelist() + if name.endswith(".dist-info/METADATA") + ) + metadata = message_from_bytes(archive.read(metadata_path)) + wheel_legal = { + path.name: archive.read(str(path)) + for path in wheel_paths + if path.parent.name == "licenses" and path.name in expected_legal + } + with tarfile.open(sdist) as archive: + members = archive.getmembers() + sdist_paths = tuple(PurePosixPath(member.name) for member in members) + sdist_legal = { + PurePosixPath(member.name).name: archive.extractfile(member).read() + for member in members + if member.isfile() + and PurePosixPath(member.name).name in expected_legal + } + pkg_info = next( + member for member in members if member.name.endswith("/PKG-INFO") + ) + sdist_metadata = message_from_bytes(archive.extractfile(pkg_info).read()) + assert wheel_legal == expected_legal + assert sdist_legal == expected_legal + for package_metadata in (metadata, sdist_metadata): + assert package_metadata["Metadata-Version"] == "2.4" + assert package_metadata["License-Expression"] == "Elastic-2.0" + assert package_metadata.get_all("License-File") == ["LICENSE", "NOTICE"] + assert package_metadata.get("License") is None + assert not any( + value.startswith("License ::") + for value in package_metadata.get_all("Classifier", []) + ) + for paths in (wheel_paths, sdist_paths): + assert any("shim_cloud" in path.parts for path in paths) + assert not any( + component in path.parts + for path in paths + for component in ("app", "shim", "shim_enterprise", "tests") + ) + assert "shim-enterprise==0.1.3" in metadata.get_all("Requires-Dist", []) + PY - name: Smoke clean enterprise wheel installation run: | uv venv "$RUNNER_TEMP/enterprise-venv" --python 3.13 @@ -333,8 +409,12 @@ jobs: dist/community/*.whl dist/enterprise/*.whl "$RUNNER_TEMP/enterprise-venv/bin/python" -c ' from importlib import import_module + from importlib.util import find_spec import shim.application import shim_enterprise.application + assert all(find_spec(module) is None for module in ( + "polar_sdk", "shim_cloud", "standardwebhooks", + )) for module in ( "shim_enterprise.workers.outbox", "shim_enterprise.workers.reconciliation", @@ -343,9 +423,13 @@ jobs: ): import_module(module) ' + # Cloud foreign keys depend on enterprise tables; roll cloud back first. + - run: uv run --locked --package shim-cloud alembic -c ee/cloud/alembic.ini downgrade base - run: uv run --locked --package shim-enterprise alembic -c ee/alembic.ini downgrade base - run: uv run --locked --package shim-enterprise alembic -c ee/alembic.ini upgrade head - run: uv run --locked --package shim-enterprise alembic -c ee/alembic.ini check + - run: uv run --locked --package shim-cloud python -m shim_cloud.migrate + - run: uv run --locked --package shim-cloud alembic -c ee/cloud/alembic.ini check - name: Build enterprise image from scratch run: docker build --pull --no-cache --file ee/Dockerfile --tag shim-enterprise:ci . - name: Verify enterprise image boundary @@ -356,7 +440,10 @@ jobs: from importlib.util import find_spec from pathlib import Path installed = {item.metadata["Name"].lower().replace("_", "-") for item in distributions()} - forbidden = {"pytest", "pytest-asyncio", "ruff", "ty"} + forbidden = { + "polar-sdk", "pytest", "pytest-asyncio", "ruff", "shim-cloud", + "standardwebhooks", "ty", + } assert {"shim-gateway", "shim-enterprise"} <= installed assert forbidden.isdisjoint(installed), sorted(forbidden & installed) for package_name, expression in ( @@ -371,6 +458,9 @@ jobs: } assert find_spec("shim") is not None assert find_spec("shim_enterprise") is not None + assert all(find_spec(module) is None for module in ( + "polar_sdk", "shim_cloud", "standardwebhooks", + )) assert all(find_spec(module) is not None for module in ( "shim_enterprise.workers.outbox", "shim_enterprise.workers.reconciliation", diff --git a/AGENTS.md b/AGENTS.md index 93e5fc1..71a0067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,16 +38,7 @@ a demonstrated consumer. Reuse the existing contract or adapter first. ## Required gate -```bash -uv lock --check -uv run --locked ruff format --check src ee/src tests ee/tests scripts ee/scripts ee/alembic -uv run --locked ruff check src ee/src tests ee/tests scripts ee/scripts ee/alembic -uv run --locked ty check -uv run --locked python -m pytest -q -uv run --locked --package shim-gateway python scripts/export_openapi.py --profile community --check -uv run --locked --package shim-enterprise python scripts/export_openapi.py --profile enterprise --check -git diff --check -``` +Run the full gate in [the developer guide](DEVELOPER_GUIDE.md#required-gates). Continuous integration runs on pull requests and on pushes to `main`. Pushing a branch verifies nothing, so run the gate locally and open a pull request rather diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30b216b..6cc9028 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,19 +39,9 @@ uv sync --locked --all-packages ## Verification -Run this before opening a pull request. Continuous integration runs the same -commands, plus the container builds. - -```console -uv lock --check -uv sync --locked --all-packages -uv run --locked ruff format --check src ee/src tests ee/tests scripts ee/scripts ee/alembic -uv run --locked ruff check src ee/src tests ee/tests scripts ee/scripts ee/alembic -uv run --locked ty check -uv run --locked python -m pytest -q -uv run --locked --package shim-gateway python scripts/export_openapi.py --profile community --check -uv run --locked --package shim-enterprise python scripts/export_openapi.py --profile enterprise --check -``` +Run the gate in [the developer guide](DEVELOPER_GUIDE.md#required-gates) before +opening a pull request. Continuous integration runs the same commands, plus the +container builds. The enterprise tests need PostgreSQL and a Redis with the search and JSON modules. `docker compose up` brings both up. diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 59bd777..da1f0c0 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -14,6 +14,7 @@ ee/src/shim_enterprise/ enterprise runtime and adapters ee/tests/ enterprise tests ee/alembic/ enterprise schema history ee/openapi/enterprise.json enterprise HTTP contract +ee/cloud/ hosted commerce package, schema, and OpenAPI contract ``` Community code must run without `ee/`, PostgreSQL, Redis, Supabase, or managed @@ -103,12 +104,13 @@ Run the locked full gate before merging a cross-package or enterprise change: ```bash uv lock --check uv sync --locked --all-packages -uv run --locked ruff format --check src ee/src tests ee/tests scripts ee/scripts ee/alembic -uv run --locked ruff check src ee/src tests ee/tests scripts ee/scripts ee/alembic +uv run --locked ruff format --check src ee/src tests ee/tests scripts ee/scripts ee/alembic ee/cloud/src ee/cloud/tests ee/cloud/alembic +uv run --locked ruff check src ee/src tests ee/tests scripts ee/scripts ee/alembic ee/cloud/src ee/cloud/tests ee/cloud/alembic uv run --locked ty check uv run --locked python -m pytest -q uv run --locked --package shim-gateway python scripts/export_openapi.py --profile community --check uv run --locked --package shim-enterprise python scripts/export_openapi.py --profile enterprise --check +uv run --locked --package shim-cloud python scripts/export_openapi.py --profile cloud --check git diff --check ``` @@ -151,3 +153,8 @@ regions' `LICENSE`, `NOTICE`, and matching package metadata. Do not add a CLA, runtime licence check, or commercial-validation policy without owner approval. Never move enterprise source or assets outside `ee/` merely to simplify packaging. + +Hosted subscriptions use the separate [cloud composition and runbook](ee/cloud/README.md). +Apply its migrations after enterprise migrations before running cloud persistence tests. +Customer packages remain selected explicitly with `--package shim-enterprise`; +`--all-packages` is a development/verification choice, not a customer artifact install. diff --git a/Dockerfile b/Dockerfile index 8b65938..4b90a21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE NOTICE ./ COPY ee/pyproject.toml ./ee/pyproject.toml +COPY ee/cloud/pyproject.toml ./ee/cloud/pyproject.toml COPY src ./src RUN uv sync --locked --no-dev --package shim-gateway --no-editable diff --git a/Dockerfile.dockerignore b/Dockerfile.dockerignore index 0302dd6..0abfad3 100644 --- a/Dockerfile.dockerignore +++ b/Dockerfile.dockerignore @@ -8,5 +8,6 @@ !src/** !ee/ !ee/pyproject.toml +!ee/cloud/pyproject.toml **/__pycache__/ **/*.py[cod] diff --git a/architecture/module_ownership.toml b/architecture/module_ownership.toml index 3f14666..7aef95d 100644 --- a/architecture/module_ownership.toml +++ b/architecture/module_ownership.toml @@ -15,6 +15,15 @@ forbidden_public_import_roots = [ "supabase", ] +# Cloud commerce dependencies must remain out of community and customer +# enterprise artifacts. +forbidden_noncloud_import_roots = [ + "polar", + "polar_sdk", + "shim_cloud", + "standardwebhooks", +] + # Exact community symbols consumed by enterprise runtime code. Any new entry is # a deliberate expansion of the supported cross-license API. [enterprise_public_api] @@ -80,6 +89,36 @@ forbidden_public_import_roots = [ "shim.secrets.credentials" = ["EphemeralProviderCredential"] "shim.services.gateway.service" = ["GatewayService"] +# Exact shared runtime symbols consumed by the hosted cloud composition. +[cloud_enterprise_api] +"shim_enterprise.api.enterprise_deps" = [ + "bearer_scheme", + "get_current_user", + "get_org_owner", +] +"shim_enterprise.application" = ["create_enterprise_app"] +"shim_enterprise.core.config" = ["settings"] +"shim_enterprise.core.database" = ["AsyncSessionLocal", "get_db"] +"shim_enterprise.outbox.handlers" = ["build_publisher"] +"shim_enterprise.outbox.publisher" = [ + "OutboxIdentityConflict", + "OutboxMessage", + "OutboxWriter", +] +"shim_enterprise.tenants.models" = ["Organization", "User"] +"shim_enterprise.tenants.plans" = [ + "apply_billing_plan", + "billing_organization_ids", + "billing_owner_email", + "claim_billing_source", + "configure_organization_quota", + "organization_plan", +] +"shim_enterprise.workers.outbox" = ["main"] + +[cloud_public_api] +"shim.gateway.contracts.ids" = ["TenantId"] + [modules] # Python files owned by the community target and free of enterprise imports. public = [ @@ -193,6 +232,7 @@ public = [ # Python files owned by commercially licensed capabilities. enterprise = [ "ee/alembic/env.py", + "ee/alembic/versions/4d4e8c6b975a_add_organization_quota_limits.py", "ee/alembic/versions/aa8b038bc50c_architecture_baseline.py", "ee/alembic/versions/c31b7a91d602_oidc_identity.py", "ee/alembic/versions/c7a108be3201_teams_roles_and_scoped_key_controls.py", @@ -360,6 +400,23 @@ enterprise = [ "ee/tests/workers/test_readiness.py", ] +# Python files shipped only in the hosted cloud composition. +cloud = [ + "ee/cloud/alembic/env.py", + "ee/cloud/alembic/versions/0001_billing_operations.py", + "ee/cloud/src/shim_cloud/__init__.py", + "ee/cloud/src/shim_cloud/api.py", + "ee/cloud/src/shim_cloud/application.py", + "ee/cloud/src/shim_cloud/billing.py", + "ee/cloud/src/shim_cloud/config.py", + "ee/cloud/src/shim_cloud/migrate.py", + "ee/cloud/src/shim_cloud/models.py", + "ee/cloud/src/shim_cloud/polar.py", + "ee/cloud/src/shim_cloud/worker.py", + "ee/cloud/tests/test_billing.py", + "ee/cloud/tests/test_polar.py", +] + # Transitional Python files that contain or exercise both ownership regions. split = [ "scripts/export_openapi.py", diff --git a/architecture/route_profiles.toml b/architecture/route_profiles.toml index c85e5aa..acc62cb 100644 --- a/architecture/route_profiles.toml +++ b/architecture/route_profiles.toml @@ -14,10 +14,94 @@ community = [ { method = "POST", path = "/v1beta/models/{model}:streamGenerateContent" }, ] enterprise = [ + { method = "DELETE", path = "/api/v1/compliance/connectors/{connector_id}" }, + { method = "DELETE", path = "/api/v1/compliance/forward-targets/{target_id}" }, + { method = "DELETE", path = "/api/v1/compliance/oversight/policies/{policy_id}" }, + { method = "DELETE", path = "/api/v1/management/api-keys/{api_key_id}" }, + { method = "DELETE", path = "/api/v1/management/cost/budgets/{budget_id}" }, + { method = "DELETE", path = "/api/v1/management/providers/{secret_id}" }, + { method = "DELETE", path = "/api/v1/management/team/invites/{invite_id}" }, + { method = "DELETE", path = "/api/v1/management/team/members/{member_id}" }, + { method = "DELETE", path = "/api/v1/management/teams/{team_id}/members/{member_id}" }, + { method = "GET", path = "/api/v1/auth/callback" }, + { method = "GET", path = "/api/v1/auth/login" }, + { method = "GET", path = "/api/v1/auth/session" }, + { method = "GET", path = "/api/v1/compliance/audit/logs" }, + { method = "GET", path = "/api/v1/compliance/connectors" }, + { method = "GET", path = "/api/v1/compliance/connectors/{connector_id}" }, + { method = "GET", path = "/api/v1/compliance/findings" }, + { method = "GET", path = "/api/v1/compliance/findings/summary" }, + { method = "GET", path = "/api/v1/compliance/forward-targets" }, + { method = "GET", path = "/api/v1/compliance/oversight" }, + { method = "GET", path = "/api/v1/compliance/oversight/policies" }, + { method = "GET", path = "/api/v1/compliance/overview" }, + { method = "GET", path = "/api/v1/management/api-keys" }, + { method = "GET", path = "/api/v1/management/auth/me" }, + { method = "GET", path = "/api/v1/management/billing/breakdown" }, + { method = "GET", path = "/api/v1/management/billing/export" }, + { method = "GET", path = "/api/v1/management/billing/usage" }, + { method = "GET", path = "/api/v1/management/cost/budgets" }, { method = "GET", path = "/api/v1/management/model-deployments" }, + { method = "GET", path = "/api/v1/management/overview" }, + { method = "GET", path = "/api/v1/management/providers" }, + { method = "GET", path = "/api/v1/management/requests" }, + { method = "GET", path = "/api/v1/management/requests/export" }, + { method = "GET", path = "/api/v1/management/settings/pii" }, + { method = "GET", path = "/api/v1/management/subscription" }, + { method = "GET", path = "/api/v1/management/team/invites" }, + { method = "GET", path = "/api/v1/management/team/members" }, + { method = "GET", path = "/api/v1/management/teams" }, + { method = "GET", path = "/api/v1/management/teams/{team_id}/members" }, + { method = "GET", path = "/api/v1/management/tier-info" }, + { method = "GET", path = "/api/v1/shared-results/{token}" }, + { method = "GET", path = "/health" }, + { method = "GET", path = "/v1/models" }, + { method = "GET", path = "/v1/models/{model_id}" }, + { method = "GET", path = "/v1/scan/usage" }, + { method = "PATCH", path = "/api/v1/compliance/connectors/{connector_id}" }, + { method = "PATCH", path = "/api/v1/compliance/forward-targets/{target_id}" }, + { method = "PATCH", path = "/api/v1/compliance/oversight/policies/{policy_id}" }, + { method = "PATCH", path = "/api/v1/management/api-keys/{api_key_id}" }, + { method = "PATCH", path = "/api/v1/management/cost/budgets/{budget_id}" }, + { method = "PATCH", path = "/api/v1/management/team/members/{member_id}" }, + { method = "POST", path = "/api/v1/auth/logout" }, + { method = "POST", path = "/api/v1/compliance/audit/anchor" }, + { method = "POST", path = "/api/v1/compliance/audit/verify" }, + { method = "POST", path = "/api/v1/compliance/connectors" }, + { method = "POST", path = "/api/v1/compliance/connectors/{connector_id}/run" }, + { method = "POST", path = "/api/v1/compliance/forward-targets" }, + { method = "POST", path = "/api/v1/compliance/oversight/evaluate" }, + { method = "POST", path = "/api/v1/compliance/oversight/policies" }, + { method = "POST", path = "/api/v1/compliance/oversight/{request_id}/decision" }, + { method = "POST", path = "/api/v1/compliance/reports/audit" }, + { method = "POST", path = "/api/v1/compliance/reports/kvkk" }, + { method = "POST", path = "/api/v1/management/api-keys" }, + { method = "POST", path = "/api/v1/management/api-keys/{api_key_id}/rotate" }, + { method = "POST", path = "/api/v1/management/cost/budgets" }, + { method = "POST", path = "/api/v1/management/cost/budgets/evaluate" }, { method = "POST", path = "/api/v1/management/model-deployments" }, - { method = "PUT", path = "/api/v1/management/model-deployments/{deployment_id}" }, { method = "POST", path = "/api/v1/management/model-deployments/{deployment_id}/health" }, + { method = "POST", path = "/api/v1/management/providers" }, + { method = "POST", path = "/api/v1/management/providers/{secret_id}/verify" }, + { method = "POST", path = "/api/v1/management/team/invites" }, + { method = "POST", path = "/api/v1/management/team/invites/accept" }, + { method = "POST", path = "/api/v1/management/teams" }, + { method = "POST", path = "/v1/chat/completions" }, + { method = "POST", path = "/v1/messages" }, + { method = "POST", path = "/v1/messages/count_tokens" }, + { method = "POST", path = "/v1/responses" }, + { method = "POST", path = "/v1/scan" }, + { method = "POST", path = "/v1/shared-results" }, + { method = "POST", path = "/v1beta/models/{model}:generateContent" }, + { method = "POST", path = "/v1beta/models/{model}:streamGenerateContent" }, + { method = "PUT", path = "/api/v1/management/auth/me" }, + { method = "PUT", path = "/api/v1/management/model-deployments/{deployment_id}" }, + { method = "PUT", path = "/api/v1/management/providers/{secret_id}" }, + { method = "PUT", path = "/api/v1/management/settings/pii" }, + { method = "PUT", path = "/api/v1/management/teams/{team_id}" }, + { method = "PUT", path = "/api/v1/management/teams/{team_id}/members/{member_id}" }, +] +cloud = [ { method = "DELETE", path = "/api/v1/compliance/connectors/{connector_id}" }, { method = "DELETE", path = "/api/v1/compliance/forward-targets/{target_id}" }, { method = "DELETE", path = "/api/v1/compliance/oversight/policies/{policy_id}" }, @@ -44,7 +128,10 @@ enterprise = [ { method = "GET", path = "/api/v1/management/billing/breakdown" }, { method = "GET", path = "/api/v1/management/billing/export" }, { method = "GET", path = "/api/v1/management/billing/usage" }, + { method = "GET", path = "/api/v1/management/cloud-billing" }, + { method = "GET", path = "/api/v1/management/cloud-billing/operations/{operation_id}" }, { method = "GET", path = "/api/v1/management/cost/budgets" }, + { method = "GET", path = "/api/v1/management/model-deployments" }, { method = "GET", path = "/api/v1/management/overview" }, { method = "GET", path = "/api/v1/management/providers" }, { method = "GET", path = "/api/v1/management/requests" }, @@ -80,13 +167,18 @@ enterprise = [ { method = "POST", path = "/api/v1/compliance/reports/kvkk" }, { method = "POST", path = "/api/v1/management/api-keys" }, { method = "POST", path = "/api/v1/management/api-keys/{api_key_id}/rotate" }, + { method = "POST", path = "/api/v1/management/cloud-billing/checkout" }, + { method = "POST", path = "/api/v1/management/cloud-billing/portal" }, { method = "POST", path = "/api/v1/management/cost/budgets" }, { method = "POST", path = "/api/v1/management/cost/budgets/evaluate" }, + { method = "POST", path = "/api/v1/management/model-deployments" }, + { method = "POST", path = "/api/v1/management/model-deployments/{deployment_id}/health" }, { method = "POST", path = "/api/v1/management/providers" }, { method = "POST", path = "/api/v1/management/providers/{secret_id}/verify" }, { method = "POST", path = "/api/v1/management/team/invites" }, { method = "POST", path = "/api/v1/management/team/invites/accept" }, { method = "POST", path = "/api/v1/management/teams" }, + { method = "POST", path = "/api/v1/webhooks/polar" }, { method = "POST", path = "/v1/chat/completions" }, { method = "POST", path = "/v1/messages" }, { method = "POST", path = "/v1/messages/count_tokens" }, @@ -96,6 +188,7 @@ enterprise = [ { method = "POST", path = "/v1beta/models/{model}:generateContent" }, { method = "POST", path = "/v1beta/models/{model}:streamGenerateContent" }, { method = "PUT", path = "/api/v1/management/auth/me" }, + { method = "PUT", path = "/api/v1/management/model-deployments/{deployment_id}" }, { method = "PUT", path = "/api/v1/management/providers/{secret_id}" }, { method = "PUT", path = "/api/v1/management/settings/pii" }, { method = "PUT", path = "/api/v1/management/teams/{team_id}" }, diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 0a69edd..4b4d526 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -34,7 +34,7 @@ steps: - --cache=true - --cache-ttl=168h - --context=. - - --dockerfile=ee/Dockerfile + - --dockerfile=ee/cloud/Dockerfile - id: acquire-deployment-lock name: gcr.io/google.com/cloudsdktool/cloud-sdk@sha256:570dc7ce2876a2810dbf80f1d2520c1654aa218a7f1347715243a79c98d166ae @@ -89,8 +89,8 @@ steps: - --project=${_DEPLOY_PROJECT_ID} - --image=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_ARTIFACT_REPOSITORY}/${_IMAGE_NAME}:$COMMIT_SHA - --region=${_REGION} - - --command=alembic - - --args=-c,ee/alembic.ini,upgrade,head + - --command=python + - --args=-m,shim_cloud.migrate - --tasks=1 - --max-retries=0 - --task-timeout=10m @@ -138,12 +138,12 @@ steps: - --subnet=${_VPC_SUBNET} - --vpc-egress=private-ranges-only - --command=uvicorn - - --args=shim_enterprise.application:create_enterprise_app,--factory,--host,0.0.0.0,--port,8000,--workers,1 + - --args=shim_cloud.application:create_cloud_app,--factory,--host,0.0.0.0,--port,8000,--workers,1 - --revision-suffix=rel-$SHORT_SHA - --deploy-health-check - --no-traffic - - --set-secrets=DATABASE_URL=${_SECRET_PREFIX}-database-url:1,REDIS_URL=${_SECRET_PREFIX}-redis-url:1,SUPABASE_URL=${_SECRET_PREFIX}-supabase-url:1,SUPABASE_KEY=${_SECRET_PREFIX}-supabase-key:1,SECRET_KEY=${_SECRET_PREFIX}-secret-key:1,SHIM_LICENSE_KEY=${_SECRET_PREFIX}-license-key:1,ENCRYPTION_KEY=${_SECRET_PREFIX}-encryption-key:1,SENTRY_DSN=${_SECRET_PREFIX}-sentry-dsn:1,OTEL_EXPORTER_OTLP_ENDPOINT=${_SECRET_PREFIX}-otel-exporter-otlp-endpoint:1,OTEL_EXPORTER_OTLP_HEADERS=${_SECRET_PREFIX}-otel-exporter-otlp-headers:1 - - --set-env-vars=^@^BACKEND_CORS_ORIGINS=${_FRONTEND_ORIGINS}@ENVIRONMENT=production@SECRET_BACKEND=gcp_secret_manager@GOOGLE_CLOUD_PROJECT=$PROJECT_ID@LOG_LEVEL=INFO@OTEL_SERVICE_NAME=${_RESOURCE_PREFIX}-gateway@DATABASE_POOL_SIZE=${_DATABASE_POOL_SIZE}@DATABASE_MAX_OVERFLOW=${_DATABASE_MAX_OVERFLOW}@COMPLIANCE_EMAIL_FROM=${_COMPLIANCE_EMAIL_FROM} + - --set-secrets=DATABASE_URL=${_SECRET_PREFIX}-database-url:1,REDIS_URL=${_SECRET_PREFIX}-redis-url:1,SUPABASE_URL=${_SECRET_PREFIX}-supabase-url:1,SUPABASE_KEY=${_SECRET_PREFIX}-supabase-key:1,SECRET_KEY=${_SECRET_PREFIX}-secret-key:1,SHIM_LICENSE_KEY=${_SECRET_PREFIX}-license-key:1,ENCRYPTION_KEY=${_SECRET_PREFIX}-encryption-key:1,SENTRY_DSN=${_SECRET_PREFIX}-sentry-dsn:1,OTEL_EXPORTER_OTLP_ENDPOINT=${_SECRET_PREFIX}-otel-exporter-otlp-endpoint:1,OTEL_EXPORTER_OTLP_HEADERS=${_SECRET_PREFIX}-otel-exporter-otlp-headers:1,POLAR_ACCESS_TOKEN=${_SECRET_PREFIX}-polar-access-token:1,POLAR_WEBHOOK_SECRET=${_SECRET_PREFIX}-polar-webhook-secret:1,POLAR_PRODUCTS=${_SECRET_PREFIX}-polar-products:1 + - --set-env-vars=^@^BACKEND_CORS_ORIGINS=${_FRONTEND_ORIGINS}@ENVIRONMENT=production@SECRET_BACKEND=gcp_secret_manager@GOOGLE_CLOUD_PROJECT=$PROJECT_ID@LOG_LEVEL=INFO@OTEL_SERVICE_NAME=${_RESOURCE_PREFIX}-gateway@DATABASE_POOL_SIZE=${_DATABASE_POOL_SIZE}@DATABASE_MAX_OVERFLOW=${_DATABASE_MAX_OVERFLOW}@COMPLIANCE_EMAIL_FROM=${_COMPLIANCE_EMAIL_FROM}@POLAR_SERVER=production@POLAR_ORGANIZATION_ID=${_POLAR_ORGANIZATION_ID}@CLOUD_DASHBOARD_URL=${_CLOUD_DASHBOARD_URL} - id: verify-gateway name: gcr.io/google.com/cloudsdktool/cloud-sdk@sha256:570dc7ce2876a2810dbf80f1d2520c1654aa218a7f1347715243a79c98d166ae @@ -221,14 +221,14 @@ steps: - --vpc-egress=private-ranges-only - --image=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_ARTIFACT_REPOSITORY}/${_IMAGE_NAME}:$COMMIT_SHA - --command=python - - --args=-m,shim_enterprise.workers.outbox + - --args=-m,shim_cloud.worker - --revision-suffix=rel-$SHORT_SHA - --no-promote - --cpu=1 - --memory=1Gi - --service-account=${_RUNTIME_SERVICE_ACCOUNT} - - --set-secrets=DATABASE_URL=${_SECRET_PREFIX}-database-url:1,REDIS_URL=${_SECRET_PREFIX}-redis-url:1,SUPABASE_URL=${_SECRET_PREFIX}-supabase-url:1,SUPABASE_KEY=${_SECRET_PREFIX}-supabase-key:1,SECRET_KEY=${_SECRET_PREFIX}-secret-key:1,SENTRY_DSN=${_SECRET_PREFIX}-sentry-dsn:1,OTEL_EXPORTER_OTLP_ENDPOINT=${_SECRET_PREFIX}-otel-exporter-otlp-endpoint:1,OTEL_EXPORTER_OTLP_HEADERS=${_SECRET_PREFIX}-otel-exporter-otlp-headers:1 - - --set-env-vars=ENVIRONMENT=production,SECRET_BACKEND=gcp_secret_manager,GOOGLE_CLOUD_PROJECT=$PROJECT_ID,LOG_LEVEL=INFO,OTEL_SERVICE_NAME=${_RESOURCE_PREFIX}-outbox-worker,DATABASE_POOL_SIZE=${_DATABASE_POOL_SIZE},DATABASE_MAX_OVERFLOW=${_DATABASE_MAX_OVERFLOW},COMPLIANCE_EMAIL_FROM=${_COMPLIANCE_EMAIL_FROM} + - --set-secrets=DATABASE_URL=${_SECRET_PREFIX}-database-url:1,REDIS_URL=${_SECRET_PREFIX}-redis-url:1,SUPABASE_URL=${_SECRET_PREFIX}-supabase-url:1,SUPABASE_KEY=${_SECRET_PREFIX}-supabase-key:1,SECRET_KEY=${_SECRET_PREFIX}-secret-key:1,SENTRY_DSN=${_SECRET_PREFIX}-sentry-dsn:1,OTEL_EXPORTER_OTLP_ENDPOINT=${_SECRET_PREFIX}-otel-exporter-otlp-endpoint:1,OTEL_EXPORTER_OTLP_HEADERS=${_SECRET_PREFIX}-otel-exporter-otlp-headers:1,POLAR_ACCESS_TOKEN=${_SECRET_PREFIX}-polar-access-token:1,POLAR_WEBHOOK_SECRET=${_SECRET_PREFIX}-polar-webhook-secret:1,POLAR_PRODUCTS=${_SECRET_PREFIX}-polar-products:1 + - --set-env-vars=ENVIRONMENT=production,SECRET_BACKEND=gcp_secret_manager,GOOGLE_CLOUD_PROJECT=$PROJECT_ID,LOG_LEVEL=INFO,OTEL_SERVICE_NAME=${_RESOURCE_PREFIX}-outbox-worker,DATABASE_POOL_SIZE=${_DATABASE_POOL_SIZE},DATABASE_MAX_OVERFLOW=${_DATABASE_MAX_OVERFLOW},COMPLIANCE_EMAIL_FROM=${_COMPLIANCE_EMAIL_FROM},POLAR_SERVER=production,POLAR_ORGANIZATION_ID=${_POLAR_ORGANIZATION_ID},CLOUD_DASHBOARD_URL=${_CLOUD_DASHBOARD_URL} - id: deploy-reconciliation-worker name: gcr.io/google.com/cloudsdktool/cloud-sdk@sha256:570dc7ce2876a2810dbf80f1d2520c1654aa218a7f1347715243a79c98d166ae @@ -481,6 +481,8 @@ substitutions: _WORKER_INSTANCES: "1" _DATABASE_POOL_SIZE: "2" _DATABASE_MAX_OVERFLOW: "1" + _POLAR_ORGANIZATION_ID: "" + _CLOUD_DASHBOARD_URL: "" options: logging: CLOUD_LOGGING_ONLY diff --git a/docs/CURRENT_ARCHITECTURE.md b/docs/CURRENT_ARCHITECTURE.md index f880cfe..3eef4b5 100644 --- a/docs/CURRENT_ARCHITECTURE.md +++ b/docs/CURRENT_ARCHITECTURE.md @@ -2,19 +2,19 @@ Status: current implementation contract -Last verified: 2026-08-28 +Last verified: 2026-09-11 This document describes the code in this branch. When it disagrees with prose, use this order of authority: -1. tests and the two checked-in OpenAPI documents; +1. tests and the three checked-in OpenAPI documents; 2. `architecture/module_ownership.toml` and `architecture/route_profiles.toml`; -3. runtime code under `src/shim` and `ee/src/shim_enterprise`; +3. runtime code under `src/shim`, `ee/src/shim_enterprise`, and `ee/cloud/src/shim_cloud`; 4. this document and the developer guide. ## Product and dependency shape -shim is a package-modular monolith with two application compositions and one +shim is a package-modular monolith with three application compositions and one lockfile. ```text @@ -26,13 +26,15 @@ src/shim ee/src/shim_enterprise +----------------------------------------------+ exact, allowlisted imports -Forbidden: shim -> shim_enterprise +shim-cloud (ee/cloud/src/shim_cloud) -> shim-enterprise -> shim +Forbidden: reverse imports; community/on-prem -> cloud/Polar ``` | Product | Runtime | State | | --- | --- | --- | | Community | `shim.application:create_community_app` | Bounded in-process state and local JSONL usage events | | Enterprise | `shim_enterprise.application:create_enterprise_app` | PostgreSQL truth, Redis acceleration, managed secrets, outbox, and workers | +| Hosted cloud | `shim_cloud.application:create_cloud_app` | Shared enterprise state plus isolated cloud commerce operations | The community package has no ORM, Alembic, Redis, Supabase, or managed-secret dependency. Enterprise imports community contracts and implementations; it does @@ -40,7 +42,7 @@ not fork the provider gateway. ## Request flow -Both products share the same inference hot path: +All products share the same inference hot path: ```text provider-native HTTP request @@ -123,11 +125,6 @@ boundaries. External effects are dispatched from committed outbox intent. The exact method/path inventories live in `architecture/route_profiles.toml`. -| Profile | Surface | Contract | -| --- | --- | --- | -| Community | OpenAI Chat and Responses; Anthropic Messages; Gemini generate and stream; model discovery; local scan; health | `openapi/community.json` | -| Enterprise | Community provider routes plus durable scan usage, management, shared results, compliance, and AI Act | `ee/openapi/enterprise.json` | - `/metrics` is intentionally excluded from OpenAPI. Enterprise provider routes must preserve the community provider request, response, selector, error, and stream contracts while adding enterprise authentication and lifecycle policy. @@ -207,6 +204,9 @@ runtime code, Alembic, and enterprise scripts remain exact-manifest-only. | --- | --- | | Community API | `shim serve` | | Enterprise API | `uvicorn shim_enterprise.application:create_enterprise_app --factory` | +| Cloud API | `uvicorn shim_cloud.application:create_cloud_app --factory` | +| Cloud migrations | `python -m shim_cloud.migrate` | +| Cloud outbox | `python -m shim_cloud.worker` | | Migrations | `alembic -c ee/alembic.ini upgrade head` | | Outbox | `python -m shim_enterprise.workers.outbox` | | Reconciliation | `python -m shim_enterprise.workers.reconciliation` | @@ -215,8 +215,10 @@ runtime code, Alembic, and enterprise scripts remain exact-manifest-only. The root Dockerfile contains only the community runtime. `ee/Dockerfile` contains both packages plus enterprise migrations and operational scripts. -Compose and Cloud Build use the enterprise image and canonical enterprise -entrypoints. +Customer Compose uses the enterprise image. Cloud Build uses `ee/cloud/Dockerfile`, +which installs `shim-cloud` and selects cloud API, outbox and migration entrypoints. +Its other workers reuse enterprise entrypoints. Cloud commerce is excluded from +customer wheels/images. See the [cloud runbook](../ee/cloud/README.md). ## Change map @@ -231,6 +233,7 @@ entrypoints. | Durable accounting | `ee/src/shim_enterprise/gateway/pipeline/quota_reservation.py` | | Tenancy and managed secrets | `ee/src/shim_enterprise/tenants/`, `ee/src/shim_enterprise/secrets/` | | Schema and migrations | `ee/src/shim_enterprise/**/models.py`, `ee/alembic/` | +| Cloud commerce | `ee/cloud/src/shim_cloud/`, `ee/cloud/alembic/` | | Route and import rules | `architecture/`, `tests/architecture/` | ## SDK update procedure @@ -244,29 +247,12 @@ Before changing an SDK pin: 4. Run real SDK clients through the ASGI transport tests. 5. Review new fields through privacy restoration, metering, and error sanitization. -6. Regenerate both OpenAPI profiles and the enterprise dashboard client. - -## Required verification - -```bash -uv lock --check -uv sync --locked --all-packages -uv run --locked ruff format --check src ee/src tests ee/tests scripts ee/scripts ee/alembic -uv run --locked ruff check src ee/src tests ee/tests scripts ee/scripts ee/alembic -uv run --locked ty check -uv run --locked python -m pytest -q -uv run --locked --package shim-gateway python scripts/export_openapi.py --profile community --check -uv run --locked --package shim-enterprise python scripts/export_openapi.py --profile enterprise --check -git diff --check -``` - -Persistence tests require PostgreSQL and Redis. The canonical Alembic config is -`ee/alembic.ini`. +6. Regenerate all affected OpenAPI profiles and the enterprise dashboard client. ## Licence boundary `LICENSE` and `NOTICE` apply Apache-2.0 outside `ee/`. `ee/LICENSE` and -`ee/NOTICE` apply Elastic-2.0 under `ee/` and name the licensor. Both package +`ee/NOTICE` apply Elastic-2.0 under `ee/` and name the licensor. All package manifests declare the matching SPDX expression and legal files; CI verifies those files in wheel and sdist metadata. Production enterprise boots verify an offline `SHIM_LICENSE_KEY` in `shim_enterprise.core.license`; no other runtime diff --git a/ee/Dockerfile b/ee/Dockerfile index 1ea6c26..987c813 100644 --- a/ee/Dockerfile +++ b/ee/Dockerfile @@ -11,13 +11,14 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE NOTICE ./ COPY ee/pyproject.toml ee/LICENSE ee/NOTICE ./ee/ +COPY ee/cloud/pyproject.toml ./ee/cloud/pyproject.toml COPY src ./src COPY ee/src ./ee/src -RUN uv sync --locked --no-dev --all-packages --no-editable +RUN uv sync --locked --no-dev --package shim-enterprise --no-editable FROM builder AS test -RUN uv sync --locked --all-packages --no-editable +RUN uv sync --locked --package shim-enterprise --no-editable FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS runtime diff --git a/ee/Dockerfile.dockerignore b/ee/Dockerfile.dockerignore index 773e17a..89d1160 100644 --- a/ee/Dockerfile.dockerignore +++ b/ee/Dockerfile.dockerignore @@ -10,6 +10,7 @@ !ee/LICENSE !ee/NOTICE !ee/pyproject.toml +!ee/cloud/pyproject.toml !ee/src/ !ee/src/** !ee/alembic.ini diff --git a/ee/alembic/versions/4d4e8c6b975a_add_organization_quota_limits.py b/ee/alembic/versions/4d4e8c6b975a_add_organization_quota_limits.py new file mode 100644 index 0000000..c7348be --- /dev/null +++ b/ee/alembic/versions/4d4e8c6b975a_add_organization_quota_limits.py @@ -0,0 +1,90 @@ +"""Add organization quota limits. + +Revision: 4d4e8c6b975a +Parent: fcc02bbe6443 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "4d4e8c6b975a" +down_revision: str | Sequence[str] | None = "fcc02bbe6443" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "organizations", + sa.Column("quota_monthly_request_limit", sa.Integer(), nullable=True), + ) + op.add_column( + "organizations", + sa.Column("quota_monthly_token_limit", sa.Integer(), nullable=True), + ) + op.add_column( + "organizations", + sa.Column( + "billing_revision", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.create_check_constraint( + "ck_organizations_quota_monthly_requests", + "organizations", + "quota_monthly_request_limit IS NULL OR quota_monthly_request_limit >= 0", + ) + op.create_check_constraint( + "ck_organizations_quota_monthly_tokens", + "organizations", + "quota_monthly_token_limit IS NULL OR quota_monthly_token_limit >= 0", + ) + op.drop_constraint( + "ck_quota_period_usage_single_scope", "quota_period_usage", type_="check" + ) + op.create_check_constraint( + "ck_quota_period_usage_single_scope", + "quota_period_usage", + "NOT (api_key_id IS NOT NULL AND team_id IS NOT NULL)", + ) + op.create_index( + "uq_quota_period_usage_organization_scope", + "quota_period_usage", + ["organization_id", "period_type", "period_start"], + unique=True, + postgresql_where=sa.text("api_key_id IS NULL AND team_id IS NULL"), + ) + + +def downgrade() -> None: + # Downgrades are disposable-only; organization allocations need this schema. + op.execute( + "DELETE FROM quota_period_usage WHERE api_key_id IS NULL AND team_id IS NULL" + ) + op.drop_index( + "uq_quota_period_usage_organization_scope", + table_name="quota_period_usage", + postgresql_where=sa.text("api_key_id IS NULL AND team_id IS NULL"), + ) + op.drop_constraint( + "ck_quota_period_usage_single_scope", "quota_period_usage", type_="check" + ) + op.create_check_constraint( + "ck_quota_period_usage_single_scope", + "quota_period_usage", + "(api_key_id IS NULL) <> (team_id IS NULL)", + ) + op.drop_constraint( + "ck_organizations_quota_monthly_tokens", "organizations", type_="check" + ) + op.drop_constraint( + "ck_organizations_quota_monthly_requests", "organizations", type_="check" + ) + op.drop_column("organizations", "billing_revision") + op.drop_column("organizations", "quota_monthly_token_limit") + op.drop_column("organizations", "quota_monthly_request_limit") diff --git a/ee/cloud/.env.example b/ee/cloud/.env.example new file mode 100644 index 0000000..d4ac775 --- /dev/null +++ b/ee/cloud/.env.example @@ -0,0 +1,10 @@ +# Cloud-only settings; combine with the enterprise environment. Never put live +# credentials in this file. Use a distinct merchant/catalog for each server. +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= +POLAR_ORGANIZATION_ID= +POLAR_SERVER=sandbox +# JSON maps configured choices to Polar product UUIDs, not price IDs. +POLAR_PRODUCTS='{"managed:monthly":"00000000-0000-4000-8000-000000000001","managed:yearly":"00000000-0000-4000-8000-000000000002","agency:monthly":"00000000-0000-4000-8000-000000000003","agency:yearly":"00000000-0000-4000-8000-000000000004"}' +CLOUD_DASHBOARD_URL=http://localhost:3000 +CLOUD_BILLING_RECONCILE_SECONDS=300 diff --git a/ee/cloud/Dockerfile b/ee/cloud/Dockerfile new file mode 100644 index 0000000..3d39bf8 --- /dev/null +++ b/ee/cloud/Dockerfile @@ -0,0 +1,43 @@ +FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 /uv /bin/uv + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_NO_CACHE=1 \ + UV_PYTHON_DOWNLOADS=0 + +WORKDIR /app + +COPY pyproject.toml uv.lock README.md LICENSE NOTICE ./ +COPY ee/pyproject.toml ee/LICENSE ee/NOTICE ./ee/ +COPY ee/cloud/pyproject.toml ee/cloud/LICENSE ee/cloud/NOTICE ./ee/cloud/ +COPY src ./src +COPY ee/src ./ee/src +COPY ee/cloud/src ./ee/cloud/src +RUN uv sync --locked --no-dev --package shim-cloud --no-editable + +FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS runtime + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN addgroup --system shim \ + && adduser --system --ingroup shim shim + +COPY --from=builder --chown=shim:shim /app/.venv ./.venv +COPY --chown=shim:shim ee/alembic.ini ./ee/alembic.ini +COPY --chown=shim:shim ee/alembic ./ee/alembic +COPY --chown=shim:shim ee/cloud/alembic.ini ./ee/cloud/alembic.ini +COPY --chown=shim:shim ee/cloud/alembic ./ee/cloud/alembic + +USER shim +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" + +CMD ["uvicorn", "shim_cloud.application:create_cloud_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/ee/cloud/Dockerfile.dockerignore b/ee/cloud/Dockerfile.dockerignore new file mode 100644 index 0000000..f876a07 --- /dev/null +++ b/ee/cloud/Dockerfile.dockerignore @@ -0,0 +1,28 @@ +** +!README.md +!LICENSE +!NOTICE +!pyproject.toml +!uv.lock +!src/ +!src/** +!ee/ +!ee/LICENSE +!ee/NOTICE +!ee/pyproject.toml +!ee/src/ +!ee/src/** +!ee/alembic.ini +!ee/alembic/ +!ee/alembic/** +!ee/cloud/ +!ee/cloud/LICENSE +!ee/cloud/NOTICE +!ee/cloud/pyproject.toml +!ee/cloud/src/ +!ee/cloud/src/** +!ee/cloud/alembic.ini +!ee/cloud/alembic/ +!ee/cloud/alembic/** +**/__pycache__/ +**/*.py[cod] diff --git a/ee/cloud/LICENSE b/ee/cloud/LICENSE new file mode 100644 index 0000000..809108b --- /dev/null +++ b/ee/cloud/LICENSE @@ -0,0 +1,93 @@ +Elastic License 2.0 + +URL: https://www.elastic.co/licensing/elastic-license + +## Acceptance + +By using the software, you agree to all of the terms and conditions below. + +## Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, +non-sublicensable, non-transferable license to use, copy, distribute, make +available, and prepare derivative works of the software, in each case subject to +the limitations and conditions below. + +## Limitations + +You may not provide the software to third parties as a hosted or managed +service, where the service provides users with access to any substantial set of +the features or functionality of the software. + +You may not move, change, disable, or circumvent the license key functionality +in the software, and you may not remove or obscure any functionality in the +software that is protected by the license key. + +You may not alter, remove, or obscure any licensing, copyright, or other notices +of the licensor in the software. Any use of the licensor’s trademarks is subject +to applicable law. + +## Patents + +The licensor grants you a license, under any patent claims the licensor can +license, or becomes able to license, to make, have made, use, sell, offer for +sale, import and have imported the software, in each case subject to the +limitations and conditions in this license. This license does not cover any +patent claims that you cause to be infringed by modifications or additions to +the software. If you or your company make any written claim that the software +infringes or contributes to infringement of any patent, your patent license for +the software granted under these terms ends immediately. If your company makes +such a claim, your patent license ends immediately for work on behalf of your +company. + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you +also gets a copy of these terms. + +If you modify the software, you must include in any modified copies of the +software prominent notices stating that you have modified the software. + +## No Other Rights + +These terms do not imply any licenses other than those expressly granted in +these terms. + +## Termination + +If you use the software in violation of these terms, such use is not licensed, +and your licenses will automatically terminate. If the licensor provides you +with a notice of your violation, and you cease all violation of this license no +later than 30 days after you receive that notice, your licenses will be +reinstated retroactively. However, if you violate these terms after such +reinstatement, any additional violation of these terms will cause your licenses +to terminate automatically and permanently. + +## No Liability + +*As far as the law allows, the software comes as is, without any warranty or +condition, and the licensor will not be liable to you for any damages arising +out of these terms or the use or nature of the software, under any kind of +legal claim.* + +## Definitions + +The **licensor** is the entity offering these terms, and the **software** is the +software the licensor makes available under these terms, including any portion +of it. + +**you** refers to the individual or entity agreeing to these terms. + +**your company** is any legal entity, sole proprietorship, or other kind of +organization that you work for, plus all organizations that have control over, +are under the control of, or are under common control with that +organization. **control** means ownership of substantially all the assets of an +entity, or the power to direct its management and policies by vote, contract, or +otherwise. Control can be direct or indirect. + +**your licenses** are all the licenses granted to you for the software under +these terms. + +**use** means anything you do with the software requiring one of your licenses. + +**trademark** means trademarks, service marks, and similar rights. diff --git a/ee/cloud/NOTICE b/ee/cloud/NOTICE new file mode 100644 index 0000000..996b021 --- /dev/null +++ b/ee/cloud/NOTICE @@ -0,0 +1,8 @@ +Shim Enterprise +Copyright 2026 Shim + +Files under this directory are licensed under the Elastic License 2.0 in +LICENSE. The licensor is Shim. + +The shim-enterprise distribution depends on shim-gateway, which is separately +licensed under the Apache License 2.0. diff --git a/ee/cloud/README.md b/ee/cloud/README.md new file mode 100644 index 0000000..7fa6ee2 --- /dev/null +++ b/ee/cloud/README.md @@ -0,0 +1,154 @@ +# Hosted cloud subscriptions + +`shim-cloud` composes the licensed enterprise gateway with Polar commerce. Ship +`ee/Dockerfile` to on-prem customers and build hosted services with +`ee/cloud/Dockerfile`. Customer packages/images do not install `shim-cloud`, +`polar-sdk`, or `standardwebhooks`. Authentication selection is independent: +cloud commerce is selected by the application/build profile, not by Supabase. + +## Configure and run + +Use the enterprise database, Redis, authentication and offline licence settings, +plus the values in [.env.example](.env.example). Keep tokens in the deployment +secret store. `POLAR_ORGANIZATION_ID` is the merchant organization; customer +`external_id` is the authenticated SHIM workspace UUID. + +```bash +uv sync --locked --package shim-cloud +uv run --locked --package shim-cloud python -m shim_cloud.migrate +uv run --locked --package shim-cloud uvicorn shim_cloud.application:create_cloud_app --factory +uv run --locked --package shim-cloud python -m shim_cloud.worker +``` + +The cloud worker **replaces** `shim_enterprise.workers.outbox`; the other +enterprise workers remain unchanged. Do not run a second plain enterprise +outbox worker against the cloud database: it cannot dispatch commerce events. +The migration command applies the existing enterprise history first, then the +independent `shim_cloud` schema/history. Never downgrade production. Quota +history survives application rollback; on-prem images cannot process cloud +commerce intents and are not a cloud rollback target. + +The website uses `NEXT_PUBLIC_BUILD_PROFILE=cloud`. Customer dashboard Docker +builds default to `onprem`; both profiles retain their independently configured +authentication mode. Match `CLOUD_DASHBOARD_URL`, CORS, and (for OIDC) +`DASHBOARD_ORIGIN` to the dashboard's origin. + +## Polar setup + +Create separate sandbox and production products. This launch has no Lemon +Squeezy subscriber migration. Preserve historical billing references in the +shared database. + +| Product choice | Recurrence | Existing website price, USD | Included UTC monthly quota | +| --- | --- | --- | --- | +| `managed:monthly` | One month | 29 | 100,000 requests / 10M tokens | +| `managed:yearly` | One year | 269 | 100,000 requests / 10M tokens | +| `agency:monthly` | One month | 149 | 1M requests / 100M tokens | +| `agency:yearly` | One year | 1,429 | 1M requests / 100M tokens | + +Use one unarchived recurring product with one fixed USD price per choice; put +its product UUID in `POLAR_PRODUCTS`. Prices must match the website before +launch; changing either side requires reviewing both. Free remains a local +plan (1,000 requests / 1M tokens monthly), and enterprise contracts remain +operator-managed. Tax, discounts and final totals appear in Polar checkout. + +Set `subscription_settings.allow_multiple_subscriptions=false`. Keep automatic +trials/discounts and dunning settings consistent with the approved offer; this +integration adds none. The worker validates merchant/catalog configuration and +performs a fresh customer-state check before checkout. Use the Polar customer +portal for plan changes, payment methods, cancellation and invoices. + +Subscribe a Standard Webhooks endpoint to `customer.state_changed`: +`https://YOUR_GATEWAY/api/v1/webhooks/polar`. Store its signing secret as +`POLAR_WEBHOOK_SECRET`. Stable `polar-sdk==0.32.0` handles API calls; pinned +`standardwebhooks` verifies the new `whsec_` secret format directly. The older +SDK convenience webhook helper transforms new secrets incorrectly. Sandbox +and production credentials, merchant IDs, products and webhook secrets must +never be mixed. + +## Access and consistency + +Owners create checkout/portal requests with a UUID `request_id`. The API commits +an operation and an outbox intent together, returning a pending operation. +Retry an ambiguous API response with the same UUID and identical choice. The +worker makes bounded SDK calls outside database transactions and stores an +encrypted result URL for ten minutes. Results are readable only by the creating +owner in that workspace and use `Cache-Control: no-store`. + +A worker interrupted after starting checkout does not blindly repeat creation; +it marks the operation failed on redelivery. A definitively failed/expired +operation requires a new request ID. Polar controls the external checkout URL's +own expiration; the local ten-minute expiry does not cancel that checkout. +The merchant's single-subscription setting prevents a second active purchase. +Rotating the enterprise `SECRET_KEY` invalidates stored transient result URLs. + +A browser return URL never grants access. Signed webhooks commit deduplicated +sync intents; the worker fetches current customer state from Polar rather than +applying an old event snapshot. Merchant/customer/workspace bindings and a +billing revision protect against tenant confusion, duplicate/out-of-order +updates and concurrent operator changes. Reconciliation enqueues the same sync +work every five minutes by default. + +Exactly one configured active/trialing subscription grants its mapped tier. +Scheduled cancellation retains access until its period ends. No active +subscription grants free; an unknown product or multiple active subscriptions +sets free with `review_required` and records a failed synchronization for review. +Vendor/network failure retains the last verified access state and retries via +the existing outbox. No new local dunning/grace policy is introduced: Polar's +customer-state access decision is authoritative. API reads/checkout returns do +not reset usage, and inference makes no Polar calls. + +Quotas are pooled across every key/team in an organization, with UTC calendar +month reset dates independent of payment recurrence. A yearly purchase still +has twelve monthly allowances. Enabling caps seeds current key usage and active +reservations; upgrades, downgrades, key creation/rotation, refunds and renewals +never reset accumulated monthly usage. On-prem tenants retain existing key/team +behavior unless their optional organization caps are explicitly configured. + +## Operations and launch verification + +Monitor existing outbox readiness, retry/dead-letter counts and lag. Inspect +cloud failures with a restricted operator query; payloads contain identifiers +and digests, not vendor customer documents or checkout URLs: + +```sql +SELECT event_type, status, count(*), min(created_at) +FROM outbox_event +WHERE event_type LIKE 'cloud.%' AND status <> 'processed' +GROUP BY event_type, status; + +SELECT id, billing_status, billing_event_at +FROM organizations +WHERE billing_source = 'polar' + AND (billing_event_at IS NULL OR billing_event_at < now() - interval '15 minutes'); +``` + +Alert on stale paid snapshots and unresolved `review_required` status. Preserve +last verified entitlements during outages and restore synchronization before +manual plan changes; the operator activation command switches authority to +`operator`, so subsequent Polar sync cannot overwrite it. Checkout attempts for +an operator plan are rejected. Do not automatically switch authority back. +Changing authority does not cancel an existing Polar subscription; settle or +cancel its payment obligations through Polar before completing that transition. + +Before production, verify the real Polar sandbox with the configured merchant: +monthly and yearly checkout, verified activation across two keys, portal plan +change/cancel, signed webhook replay and delayed delivery, period expiration, +and outage recovery. Then validate the production product amounts, webhook +health, scopes, merchant single-subscription setting and dashboard return URL. +These checks need account credentials and actual vendor checkout; mocked HTTP +integration tests do not replace them. Existing API token scopes must permit +organization/product reads, customer-state reads, checkouts and customer sessions. + +Local verification, in addition to the repository gate: + +```bash +uv run --locked --package shim-cloud python -m alembic -c ee/cloud/alembic.ini check +uv run --locked --package shim-cloud python -m pytest -q ee/cloud/tests +uv run --locked --package shim-cloud python scripts/export_openapi.py --profile cloud --check +``` + +References: [Python SDK](https://polar.sh/docs/integrate/sdk/python), +[customer state](https://polar.sh/docs/integrate/customer-state), +[webhook delivery](https://polar.sh/docs/integrate/webhooks/delivery), +[subscriptions](https://polar.sh/docs/features/subscriptions/introduction). diff --git a/ee/cloud/alembic.ini b/ee/cloud/alembic.ini new file mode 100644 index 0000000..9ba6adb --- /dev/null +++ b/ee/cloud/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = %(here)s/alembic +prepend_sys_path = %(here)s/src +path_separator = os +output_encoding = utf-8 + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = concise + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic +propagate = 0 + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = concise + +[formatter_concise] +format = %(levelname)s [%(name)s] %(message)s diff --git a/ee/cloud/alembic/env.py b/ee/cloud/alembic/env.py new file mode 100644 index 0000000..598357c --- /dev/null +++ b/ee/cloud/alembic/env.py @@ -0,0 +1,65 @@ +"""Cloud schema only; enterprise owns the public schema and its migration history.""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import Connection, pool, text +from sqlalchemy.ext.asyncio import async_engine_from_config + +from shim_cloud.models import CloudBase +from shim_enterprise.core.config import settings + +config = context.config +if config.config_file_name: + fileConfig(config.config_file_name) +config.set_main_option("sqlalchemy.url", str(settings.DATABASE_URL).replace("%", "%%")) + + +def include_name( + name: str | None, type_: str, parent_names: dict[str, str | None] +) -> bool: + if type_ == "schema": + return name == "shim_cloud" + return True + + +def run(connection: Connection) -> None: + connection.execute(text("CREATE SCHEMA IF NOT EXISTS shim_cloud")) + connection.commit() + context.configure( + connection=connection, + target_metadata=CloudBase.metadata, + version_table_schema="shim_cloud", + include_schemas=True, + include_name=include_name, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def online() -> None: + engine = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with engine.connect() as connection: + await connection.run_sync(run) + await engine.dispose() + + +if context.is_offline_mode(): + context.configure( + url=str(settings.DATABASE_URL), + target_metadata=CloudBase.metadata, + version_table_schema="shim_cloud", + literal_binds=True, + ) + context.execute("CREATE SCHEMA IF NOT EXISTS shim_cloud") + with context.begin_transaction(): + context.run_migrations() +else: + asyncio.run(online()) diff --git a/ee/cloud/alembic/versions/0001_billing_operations.py b/ee/cloud/alembic/versions/0001_billing_operations.py new file mode 100644 index 0000000..f15bac9 --- /dev/null +++ b/ee/cloud/alembic/versions/0001_billing_operations.py @@ -0,0 +1,62 @@ +"""Cloud-only transient checkout/portal operations and initial org quota opt-in.""" + +from alembic import op +import sqlalchemy as sa + +revision = "cloud_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "billing_operation", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("created_by", sa.Uuid(), nullable=False), + sa.Column("request_id", sa.Uuid(), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("product_id", sa.Text()), + sa.Column("status", sa.Text(), nullable=False, server_default="pending"), + sa.Column("result_ciphertext", sa.Text()), + sa.Column("error", sa.Text()), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["organization_id"], ["public.organizations.id"]), + sa.ForeignKeyConstraint(["created_by"], ["public.users.id"]), + sa.UniqueConstraint( + "organization_id", "request_id", name="uq_billing_operation_request" + ), + sa.CheckConstraint( + "kind IN ('checkout', 'portal')", name="ck_billing_operation_kind" + ), + sa.CheckConstraint( + "status IN ('pending', 'processing', 'complete', 'failed', 'expired')", + name="ck_billing_operation_status", + ), + schema="shim_cloud", + ) + op.create_index( + "ix_billing_operation_expiry", + "billing_operation", + ["expires_at"], + schema="shim_cloud", + ) + op.execute(""" + UPDATE organizations SET + quota_monthly_request_limit = tier_definitions.monthly_request_limit, + quota_monthly_token_limit = tier_definitions.monthly_token_limit + FROM tier_definitions WHERE organizations.tier = tier_definitions.slug + """) + + +def downgrade() -> None: + op.drop_table("billing_operation", schema="shim_cloud") + # Retain quota opt-in and all usage history; rollback must not widen allowances. diff --git a/ee/cloud/openapi/cloud.json b/ee/cloud/openapi/cloud.json new file mode 100644 index 0000000..4548e3d --- /dev/null +++ b/ee/cloud/openapi/cloud.json @@ -0,0 +1,15874 @@ +{ + "components": { + "schemas": { + "AcceptTeamInvite": { + "properties": { + "token": { + "maxLength": 512, + "minLength": 32, + "title": "Token", + "type": "string" + } + }, + "required": [ + "token" + ], + "title": "AcceptTeamInvite", + "type": "object" + }, + "AnchorMismatch": { + "properties": { + "anchor_date": { + "format": "date", + "title": "Anchor Date", + "type": "string" + }, + "live_row_count": { + "minimum": 0.0, + "title": "Live Row Count", + "type": "integer" + }, + "recomputed_root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Recomputed Root" + }, + "stored_root": { + "title": "Stored Root", + "type": "string" + }, + "stored_row_count": { + "minimum": 0.0, + "title": "Stored Row Count", + "type": "integer" + } + }, + "required": [ + "anchor_date", + "stored_root", + "recomputed_root", + "stored_row_count", + "live_row_count" + ], + "title": "AnchorMismatch", + "type": "object" + }, + "AnchorResult": { + "properties": { + "anchor_date": { + "format": "date", + "title": "Anchor Date", + "type": "string" + }, + "external_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Ref" + }, + "root_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Root Hash" + }, + "row_count": { + "minimum": 0.0, + "title": "Row Count", + "type": "integer" + } + }, + "required": [ + "anchor_date", + "row_count" + ], + "title": "AnchorResult", + "type": "object" + }, + "AnthropicErrorDetail": { + "properties": { + "message": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "title": "AnthropicErrorDetail", + "type": "object" + }, + "AnthropicErrorResponse": { + "properties": { + "error": { + "$ref": "#/components/schemas/AnthropicErrorDetail" + }, + "request_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Id" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "error" + ], + "title": "AnthropicErrorResponse", + "type": "object" + }, + "AnthropicModelListView": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AnthropicModelRecordView" + }, + "title": "Data", + "type": "array" + }, + "first_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "First Id" + }, + "has_more": { + "title": "Has More", + "type": "boolean" + }, + "last_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Id" + } + }, + "required": [ + "data", + "has_more", + "first_id", + "last_id" + ], + "title": "AnthropicModelListView", + "type": "object" + }, + "AnthropicModelRecordView": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "created_at", + "display_name", + "type" + ], + "title": "AnthropicModelRecordView", + "type": "object" + }, + "ApiAuth": { + "additionalProperties": false, + "description": "The generic reusable api auth config.\n\nDeprecated. Please use AuthConfig (google/cloud/aiplatform/master/auth.proto)\ninstead. This data type is not supported in Gemini API.", + "properties": { + "apiKeyConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiAuthApiKeyConfig" + }, + { + "type": "null" + } + ], + "description": "The API secret." + } + }, + "title": "ApiAuth", + "type": "object" + }, + "ApiAuthApiKeyConfig": { + "additionalProperties": false, + "description": "The API secret. This data type is not supported in Gemini API.", + "properties": { + "apiKeySecretVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The SecretManager secret version resource name storing API key. e.g. projects/{project}/secrets/{secret}/versions/{version}", + "title": "Apikeysecretversion" + }, + "apiKeyString": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The API key string. Either this or `api_key_secret_version` must be set.", + "title": "Apikeystring" + } + }, + "title": "ApiAuthApiKeyConfig", + "type": "object" + }, + "ApiKeyConfig": { + "additionalProperties": false, + "description": "Config for authentication with API key.\n\nThis data type is not supported in Gemini API.", + "properties": { + "apiKeySecret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The name of the SecretManager secret version resource storing the API key. Format: `projects/{project}/secrets/{secrete}/versions/{version}` - If both `api_key_secret` and `api_key_string` are specified, this field takes precedence over `api_key_string`. - If specified, the `secretmanager.versions.access` permission should be granted to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents) on the specified resource.", + "title": "Apikeysecret" + }, + "apiKeyString": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The API key to be used in the request directly.", + "title": "Apikeystring" + }, + "httpElementLocation": { + "anyOf": [ + { + "$ref": "#/components/schemas/HttpElementLocation" + }, + { + "type": "null" + } + ], + "description": "Optional. The location of the API key." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The parameter name of the API key. E.g. If the API request is \"https://example.com/act?api_key=\", \"api_key\" would be the parameter name.", + "title": "Name" + } + }, + "title": "ApiKeyConfig", + "type": "object" + }, + "ApiKeyInput": { + "properties": { + "allowed_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "maxItems": 200, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Allowed Models" + }, + "cost_center": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Center" + }, + "name": { + "maxLength": 50, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "team": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team" + }, + "team_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Id" + } + }, + "required": [ + "name" + ], + "title": "ApiKeyInput", + "type": "object" + }, + "ApiKeyPatch": { + "properties": { + "allowed_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "maxItems": 200, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Allowed Models" + }, + "cost_center": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Center" + }, + "team": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team" + }, + "team_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Id" + } + }, + "title": "ApiKeyPatch", + "type": "object" + }, + "ApiKeyView": { + "properties": { + "allowed_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Allowed Models" + }, + "cost_center": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Center" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "is_active": { + "title": "Is Active", + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "prefix": { + "title": "Prefix", + "type": "string" + }, + "team": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team" + }, + "team_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Id" + }, + "tier": { + "title": "Tier", + "type": "string" + } + }, + "required": [ + "id", + "name", + "prefix", + "tier", + "created_at", + "is_active", + "expires_at", + "cost_center", + "team", + "team_id", + "allowed_models" + ], + "title": "ApiKeyView", + "type": "object" + }, + "ApiSpec": { + "description": "The API spec that the external API implements.\n\nThis enum is not supported in Gemini API.", + "enum": [ + "API_SPEC_UNSPECIFIED", + "SIMPLE_SEARCH", + "ELASTIC_SEARCH" + ], + "title": "ApiSpec", + "type": "string" + }, + "AspectRatio": { + "description": "The aspect ratio for the image output.", + "enum": [ + "ASPECT_RATIO_UNSPECIFIED", + "ASPECT_RATIO_ONE_BY_ONE", + "ASPECT_RATIO_TWO_BY_THREE", + "ASPECT_RATIO_THREE_BY_TWO", + "ASPECT_RATIO_THREE_BY_FOUR", + "ASPECT_RATIO_FOUR_BY_THREE", + "ASPECT_RATIO_FOUR_BY_FIVE", + "ASPECT_RATIO_FIVE_BY_FOUR", + "ASPECT_RATIO_NINE_BY_SIXTEEN", + "ASPECT_RATIO_SIXTEEN_BY_NINE", + "ASPECT_RATIO_TWENTY_ONE_BY_NINE", + "ASPECT_RATIO_ONE_BY_EIGHT", + "ASPECT_RATIO_EIGHT_BY_ONE", + "ASPECT_RATIO_ONE_BY_FOUR", + "ASPECT_RATIO_FOUR_BY_ONE" + ], + "title": "AspectRatio", + "type": "string" + }, + "AudioResponseFormat": { + "additionalProperties": false, + "description": "Configuration for audio-specific output formatting.", + "properties": { + "bitRate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. Bit rate in bits per second (bps). Only applicable for compressed formats (MP3, Opus).", + "title": "Bitrate" + }, + "delivery": { + "anyOf": [ + { + "$ref": "#/components/schemas/Delivery" + }, + { + "type": "null" + } + ], + "description": "Optional. Delivery mode for the generated content." + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The MIME type of the audio output.", + "title": "Mimetype" + }, + "sampleRate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. Sample rate for the generated audio in Hertz.", + "title": "Samplerate" + } + }, + "title": "AudioResponseFormat", + "type": "object" + }, + "AudioTranscriptionConfig": { + "additionalProperties": false, + "description": "The audio transcription configuration in Setup.", + "properties": { + "adaptationPhrases": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Deprecated. A list of phrases used for speech adaptation, which biases the ASR model to improve recognition of these specific terms.", + "title": "Adaptationphrases" + }, + "customVocabulary": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "A list of custom vocabulary phrases, which biases the ASR model to improve recognition of these specific terms.", + "title": "Customvocabulary" + }, + "diarization": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Configures speaker diarization.\n ", + "title": "Diarization" + }, + "languageAuto": { + "anyOf": [ + { + "$ref": "#/components/schemas/LanguageAuto" + }, + { + "type": "null" + } + ], + "description": "Deprecated: Auto-detection is now the default when language_codes is omitted. This field will be removed in a future version." + }, + "languageCodes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "BCP-47 language codes providing hints about the languages present in the audio. If omitted or empty, defaults to automatic language detection.", + "title": "Languagecodes" + }, + "languageHints": { + "anyOf": [ + { + "$ref": "#/components/schemas/LanguageHints" + }, + { + "type": "null" + } + ], + "description": "Deprecated: Use top-level language_codes instead. This field will be removed in a future version." + }, + "wordTimestamp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Configures word-level timestamp generation.\n ", + "title": "Wordtimestamp" + } + }, + "title": "AudioTranscriptionConfig", + "type": "object" + }, + "AuditLogPage": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AuditLogRead" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "maximum": 500.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "offset": { + "minimum": 0.0, + "title": "Offset", + "type": "integer" + }, + "total": { + "minimum": 0.0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "AuditLogPage", + "type": "object" + }, + "AuditLogRead": { + "properties": { + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actor" + }, + "actor_type": { + "anyOf": [ + { + "enum": [ + "api_key", + "user_jwt", + "internal" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actor Type" + }, + "api_key_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key Id" + }, + "completion_tokens": { + "minimum": 0.0, + "title": "Completion Tokens", + "type": "integer" + }, + "cost_usd": { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "title": "Cost Usd", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Endpoint" + }, + "event_type": { + "title": "Event Type", + "type": "string" + }, + "gateway_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gateway Version" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "input_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Input Hash" + }, + "is_cache_hit": { + "title": "Is Cache Hit", + "type": "boolean" + }, + "latency_ms": { + "minimum": 0.0, + "title": "Latency Ms", + "type": "integer" + }, + "lifecycle_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lifecycle Status" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "output_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Output Hash" + }, + "pii_detected": { + "title": "Pii Detected", + "type": "boolean" + }, + "pii_entities": { + "additionalProperties": { + "type": "integer" + }, + "title": "Pii Entities", + "type": "object" + }, + "policy_verdicts": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Policy Verdicts", + "type": "array" + }, + "prev_hash": { + "title": "Prev Hash", + "type": "string" + }, + "prompt_tokens": { + "minimum": 0.0, + "title": "Prompt Tokens", + "type": "integer" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider" + }, + "request_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Id" + }, + "row_hash": { + "title": "Row Hash", + "type": "string" + }, + "seq": { + "exclusiveMinimum": 0.0, + "title": "Seq", + "type": "integer" + } + }, + "required": [ + "id", + "seq", + "created_at", + "event_type", + "pii_detected", + "prompt_tokens", + "completion_tokens", + "is_cache_hit", + "latency_ms", + "cost_usd", + "prev_hash", + "row_hash" + ], + "title": "AuditLogRead", + "type": "object" + }, + "AuditOverview": { + "properties": { + "coverage": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Coverage", + "type": "number" + }, + "last_anchor_date": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Anchor Date" + }, + "last_anchor_root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Anchor Root" + }, + "retention_days": { + "exclusiveMinimum": 0.0, + "title": "Retention Days", + "type": "integer" + }, + "retention_floor_days": { + "exclusiveMinimum": 0.0, + "title": "Retention Floor Days", + "type": "integer" + }, + "total_rows": { + "minimum": 0.0, + "title": "Total Rows", + "type": "integer" + } + }, + "required": [ + "total_rows", + "coverage", + "retention_days", + "retention_floor_days" + ], + "title": "AuditOverview", + "type": "object" + }, + "AuditReportRequest": { + "properties": { + "connector_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connector Id" + }, + "end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + }, + "format": { + "default": "pdf", + "enum": [ + "pdf", + "csv" + ], + "title": "Format", + "type": "string" + }, + "frameworks": { + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Frameworks", + "type": "array" + }, + "start": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + "title": "AuditReportRequest", + "type": "object" + }, + "AuthConfig": { + "additionalProperties": false, + "description": "The authentication config to access the API.", + "properties": { + "apiKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The authentication config to access the API. Only API key is supported. This field is not supported in Gemini API.", + "title": "Apikey" + }, + "apiKeyConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiKeyConfig" + }, + { + "type": "null" + } + ], + "description": "Config for API key auth." + }, + "authType": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthType" + }, + { + "type": "null" + } + ], + "description": "Type of auth scheme." + }, + "googleServiceAccountConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthConfigGoogleServiceAccountConfig" + }, + { + "type": "null" + } + ], + "description": "Config for Google Service Account auth." + }, + "httpBasicAuthConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthConfigHttpBasicAuthConfig" + }, + { + "type": "null" + } + ], + "description": "Config for HTTP Basic auth." + }, + "oauthConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthConfigOauthConfig" + }, + { + "type": "null" + } + ], + "description": "Config for user oauth." + }, + "oidcConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthConfigOidcConfig" + }, + { + "type": "null" + } + ], + "description": "Config for user OIDC auth." + } + }, + "title": "AuthConfig", + "type": "object" + }, + "AuthConfigGoogleServiceAccountConfig": { + "additionalProperties": false, + "description": "Config for Google Service Account Authentication.\n\nThis data type is not supported in Gemini API.", + "properties": { + "serviceAccount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The service account that the extension execution service runs as. - If the service account is specified, the `iam.serviceAccounts.getAccessToken` permission should be granted to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents) on the specified service account. - If not specified, the Vertex AI Extension Service Agent will be used to execute the Extension.", + "title": "Serviceaccount" + } + }, + "title": "AuthConfigGoogleServiceAccountConfig", + "type": "object" + }, + "AuthConfigHttpBasicAuthConfig": { + "additionalProperties": false, + "description": "Config for HTTP Basic Authentication.\n\nThis data type is not supported in Gemini API.", + "properties": { + "credentialSecret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The name of the SecretManager secret version resource storing the base64 encoded credentials. Format: `projects/{project}/secrets/{secrete}/versions/{version}` - If specified, the `secretmanager.versions.access` permission should be granted to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents) on the specified resource.", + "title": "Credentialsecret" + } + }, + "title": "AuthConfigHttpBasicAuthConfig", + "type": "object" + }, + "AuthConfigOauthConfig": { + "additionalProperties": false, + "description": "Config for user oauth. This data type is not supported in Gemini API.", + "properties": { + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Access token for extension endpoint. Only used to propagate token from [[ExecuteExtensionRequest.runtime_auth_config]] at request time.", + "title": "Accesstoken" + }, + "serviceAccount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The service account used to generate access tokens for executing the Extension. - If the service account is specified, the `iam.serviceAccounts.getAccessToken` permission should be granted to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents) on the provided service account.", + "title": "Serviceaccount" + } + }, + "title": "AuthConfigOauthConfig", + "type": "object" + }, + "AuthConfigOidcConfig": { + "additionalProperties": false, + "description": "Config for user OIDC auth.\n\nThis data type is not supported in Gemini API.", + "properties": { + "idToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "OpenID Connect formatted ID token for extension endpoint. Only used to propagate token from [[ExecuteExtensionRequest.runtime_auth_config]] at request time.", + "title": "Idtoken" + }, + "serviceAccount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The service account used to generate an OpenID Connect (OIDC)-compatible JWT token signed by the Google OIDC Provider (accounts.google.com) for extension endpoint (https://cloud.google.com/iam/docs/create-short-lived-credentials-direct#sa-credentials-oidc). - The audience for the token will be set to the URL in the server url defined in the OpenApi spec. - If the service account is provided, the service account should grant `iam.serviceAccounts.getOpenIdToken` permission to Vertex AI Extension Service Agent (https://cloud.google.com/vertex-ai/docs/general/access-control#service-agents).", + "title": "Serviceaccount" + } + }, + "title": "AuthConfigOidcConfig", + "type": "object" + }, + "AuthType": { + "description": "Type of auth scheme. This enum is not supported in Gemini API.", + "enum": [ + "AUTH_TYPE_UNSPECIFIED", + "NO_AUTH", + "API_KEY_AUTH", + "HTTP_BASIC_AUTH", + "GOOGLE_SERVICE_ACCOUNT_AUTH", + "OAUTH", + "OIDC_AUTH" + ], + "title": "AuthType", + "type": "string" + }, + "Behavior": { + "description": "Specifies the function Behavior.\n\nIf not specified, the system keeps the current function call behavior. This\nfield is currently only supported by the BidiGenerateContent method.", + "enum": [ + "UNSPECIFIED", + "BLOCKING", + "NON_BLOCKING" + ], + "title": "Behavior", + "type": "string" + }, + "BillingBreakdownRow": { + "properties": { + "completion_tokens": { + "minimum": 0.0, + "title": "Completion Tokens", + "type": "integer" + }, + "cost_complete": { + "default": true, + "title": "Cost Complete", + "type": "boolean" + }, + "cost_usd": { + "anyOf": [ + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Usd" + }, + "key": { + "minLength": 1, + "title": "Key", + "type": "string" + }, + "prompt_tokens": { + "minimum": 0.0, + "title": "Prompt Tokens", + "type": "integer" + }, + "request_count": { + "minimum": 0.0, + "title": "Request Count", + "type": "integer" + }, + "unpriced_requests": { + "default": 0, + "title": "Unpriced Requests", + "type": "integer" + } + }, + "required": [ + "key", + "request_count", + "prompt_tokens", + "completion_tokens", + "cost_usd" + ], + "title": "BillingBreakdownRow", + "type": "object" + }, + "BillingBreakdownView": { + "properties": { + "group_by": { + "enum": [ + "model", + "tag", + "cost_center", + "provider", + "team" + ], + "title": "Group By", + "type": "string" + }, + "limit": { + "maximum": 500.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "period": { + "$ref": "#/components/schemas/BillingPeriodView" + }, + "rows": { + "items": { + "$ref": "#/components/schemas/BillingBreakdownRow" + }, + "title": "Rows", + "type": "array" + } + }, + "required": [ + "period", + "group_by", + "rows", + "limit" + ], + "title": "BillingBreakdownView", + "type": "object" + }, + "BillingOperationView": { + "properties": { + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "processing", + "complete", + "failed", + "expired" + ], + "title": "Status", + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + } + }, + "required": [ + "id", + "status" + ], + "title": "BillingOperationView", + "type": "object" + }, + "BillingPeriodView": { + "properties": { + "end": { + "format": "date-time", + "title": "End", + "type": "string" + }, + "start": { + "format": "date-time", + "title": "Start", + "type": "string" + } + }, + "required": [ + "start", + "end" + ], + "title": "BillingPeriodView", + "type": "object" + }, + "BillingUsageView": { + "properties": { + "cost_complete": { + "default": true, + "title": "Cost Complete", + "type": "boolean" + }, + "daily_usage": { + "items": { + "$ref": "#/components/schemas/DailyUsageView" + }, + "title": "Daily Usage", + "type": "array" + }, + "period": { + "$ref": "#/components/schemas/BillingPeriodView" + }, + "total_cost": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Total Cost" + }, + "unpriced_requests": { + "default": 0, + "title": "Unpriced Requests", + "type": "integer" + } + }, + "required": [ + "period", + "daily_usage", + "total_cost" + ], + "title": "BillingUsageView", + "type": "object" + }, + "Blob": { + "additionalProperties": false, + "description": "A content blob.\n\nA Blob contains data of a specific media type. It is used to represent images,\naudio, and video.", + "properties": { + "data": { + "anyOf": [ + { + "contentEncoding": "base64", + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The raw bytes of the data.", + "title": "Data" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The display name of the blob. Used to provide a label or filename to distinguish blobs. This field is only returned in `PromptMessage` for prompt management. It is used in the Gemini calls only when server-side tools (`code_execution`, `google_search`, and `url_context`) are enabled. This field is not supported in Gemini API.", + "title": "Displayname" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The IANA standard MIME type of the source data.", + "title": "Mimetype" + } + }, + "title": "Blob", + "type": "object" + }, + "BudgetEvaluationItem": { + "properties": { + "budget_id": { + "format": "uuid", + "title": "Budget Id", + "type": "string" + }, + "enqueued": { + "title": "Enqueued", + "type": "integer" + }, + "fired": { + "items": { + "type": "number" + }, + "title": "Fired", + "type": "array" + }, + "fraction": { + "title": "Fraction", + "type": "number" + } + }, + "required": [ + "budget_id", + "fraction", + "fired", + "enqueued" + ], + "title": "BudgetEvaluationItem", + "type": "object" + }, + "BudgetEvaluationView": { + "properties": { + "period": { + "title": "Period", + "type": "string" + }, + "results": { + "items": { + "$ref": "#/components/schemas/BudgetEvaluationItem" + }, + "title": "Results", + "type": "array" + } + }, + "required": [ + "period", + "results" + ], + "title": "BudgetEvaluationView", + "type": "object" + }, + "BudgetInput": { + "properties": { + "alert_thresholds": { + "items": { + "type": "number" + }, + "maxItems": 10, + "title": "Alert Thresholds", + "type": "array" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "limit_tokens": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit Tokens" + }, + "limit_usd": { + "anyOf": [ + { + "minimum": 0.0, + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Limit Usd" + }, + "notify_targets": { + "items": { + "$ref": "#/components/schemas/NotificationTargetInput" + }, + "maxItems": 10, + "title": "Notify Targets", + "type": "array" + }, + "scope_type": { + "enum": [ + "tag", + "team", + "org" + ], + "title": "Scope Type", + "type": "string" + }, + "scope_value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scope Value" + } + }, + "required": [ + "scope_type" + ], + "title": "BudgetInput", + "type": "object" + }, + "BudgetPatch": { + "properties": { + "alert_thresholds": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "maxItems": 10, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Alert Thresholds" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "limit_tokens": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit Tokens" + }, + "limit_usd": { + "anyOf": [ + { + "minimum": 0.0, + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Limit Usd" + }, + "notify_targets": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/NotificationTargetInput" + }, + "maxItems": 10, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Notify Targets" + } + }, + "title": "BudgetPatch", + "type": "object" + }, + "BudgetView": { + "properties": { + "alert_thresholds": { + "items": { + "type": "number" + }, + "title": "Alert Thresholds", + "type": "array" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "limit_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit Tokens" + }, + "limit_usd": { + "anyOf": [ + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Limit Usd" + }, + "notify_targets": { + "items": { + "$ref": "#/components/schemas/NotificationTargetView" + }, + "title": "Notify Targets", + "type": "array" + }, + "organization_id": { + "format": "uuid", + "title": "Organization Id", + "type": "string" + }, + "period": { + "title": "Period", + "type": "string" + }, + "scope_type": { + "enum": [ + "tag", + "team", + "org" + ], + "title": "Scope Type", + "type": "string" + }, + "scope_value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scope Value" + } + }, + "required": [ + "id", + "organization_id", + "scope_type", + "scope_value", + "period", + "limit_usd", + "limit_tokens", + "alert_thresholds", + "notify_targets", + "enabled", + "created_at" + ], + "title": "BudgetView", + "type": "object" + }, + "ChainBreak": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "seq": { + "exclusiveMinimum": 0.0, + "title": "Seq", + "type": "integer" + } + }, + "required": [ + "seq", + "id", + "reason" + ], + "title": "ChainBreak", + "type": "object" + }, + "ChatRequest": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "ChatRequest", + "type": "object" + }, + "CheckoutRequest": { + "additionalProperties": false, + "properties": { + "interval": { + "enum": [ + "monthly", + "yearly" + ], + "title": "Interval", + "type": "string" + }, + "plan": { + "enum": [ + "managed", + "agency" + ], + "title": "Plan", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "plan", + "interval" + ], + "title": "CheckoutRequest", + "type": "object" + }, + "CloudBillingView": { + "properties": { + "can_checkout": { + "title": "Can Checkout", + "type": "boolean" + }, + "can_manage": { + "title": "Can Manage", + "type": "boolean" + }, + "can_open_portal": { + "title": "Can Open Portal", + "type": "boolean" + }, + "cancel_at_period_end": { + "title": "Cancel At Period End", + "type": "boolean" + }, + "current_period_end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Current Period End" + }, + "plan": { + "title": "Plan", + "type": "string" + }, + "products": { + "items": { + "$ref": "#/components/schemas/ProductChoice" + }, + "title": "Products", + "type": "array" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "plan", + "status", + "source", + "current_period_end", + "cancel_at_period_end", + "can_manage", + "can_checkout", + "can_open_portal", + "products" + ], + "title": "CloudBillingView", + "type": "object" + }, + "CodeExecutionResult": { + "additionalProperties": false, + "description": "Result of executing the ExecutableCode.\n\nGenerated only when the `CodeExecution` tool is used.", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The identifier of the `ExecutableCode` part this result is for. Only populated if the corresponding `ExecutableCode` has an id. This field is not supported in Vertex AI.", + "title": "Id" + }, + "outcome": { + "anyOf": [ + { + "$ref": "#/components/schemas/Outcome" + }, + { + "type": "null" + } + ], + "description": "Required. Outcome of the code execution." + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Contains stdout when code execution is successful, stderr or other description otherwise.", + "title": "Output" + } + }, + "title": "CodeExecutionResult", + "type": "object" + }, + "CodexModelListView": { + "properties": { + "models": { + "items": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + }, + "title": "Models", + "type": "array" + } + }, + "required": [ + "models" + ], + "title": "CodexModelListView", + "type": "object" + }, + "ComputerUse": { + "additionalProperties": false, + "description": "Tool to support computer use.", + "properties": { + "disabledSafetyPolicies": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SafetyPolicy" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. Disabled safety policies for computer use. This field is not supported in Vertex AI.", + "title": "Disabledsafetypolicies" + }, + "enablePromptInjectionDetection": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Enables the prompt injection detection check on computer-use request.", + "title": "Enablepromptinjectiondetection" + }, + "environment": { + "anyOf": [ + { + "$ref": "#/components/schemas/Environment" + }, + { + "type": "null" + } + ], + "description": "Required. The environment being operated." + }, + "excludedPredefinedFunctions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. By default, [predefined functions](https://cloud.google.com/vertex-ai/generative-ai/docs/computer-use#supported-actions) are included in the final model call. Some of them can be explicitly excluded from being automatically included. This can serve two purposes: 1. Using a more restricted / different action space. 2. Improving the definitions / instructions of predefined functions.", + "title": "Excludedpredefinedfunctions" + } + }, + "title": "ComputerUse", + "type": "object" + }, + "ConnectorCreate": { + "properties": { + "api_key": { + "minLength": 8, + "title": "Api Key", + "type": "string" + }, + "config": { + "additionalProperties": true, + "title": "Config", + "type": "object" + }, + "provider": { + "enum": [ + "anthropic", + "openai" + ], + "title": "Provider", + "type": "string" + } + }, + "required": [ + "provider", + "api_key" + ], + "title": "ConnectorCreate", + "type": "object" + }, + "ConnectorOverview": { + "properties": { + "errored": { + "minimum": 0.0, + "title": "Errored", + "type": "integer" + }, + "healthy": { + "minimum": 0.0, + "title": "Healthy", + "type": "integer" + }, + "max_lag_seconds": { + "minimum": 0.0, + "title": "Max Lag Seconds", + "type": "number" + }, + "total": { + "minimum": 0.0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "total", + "healthy", + "errored", + "max_lag_seconds" + ], + "title": "ConnectorOverview", + "type": "object" + }, + "ConnectorRead": { + "properties": { + "backfill_completed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backfill Completed At" + }, + "backfill_started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backfill Started At" + }, + "config": { + "additionalProperties": true, + "title": "Config", + "type": "object" + }, + "consecutive_errors": { + "default": 0, + "title": "Consecutive Errors", + "type": "integer" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "healthy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Healthy" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "lag_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Lag Seconds" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Error" + }, + "last_run_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Run At" + }, + "last_success_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Success At" + }, + "masked_key": { + "title": "Masked Key", + "type": "string" + }, + "organization_id": { + "format": "uuid", + "title": "Organization Id", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "retention_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Retention Days" + }, + "scope_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scope Id" + }, + "scope_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scope Type" + }, + "status": { + "enum": [ + "active", + "paused", + "error" + ], + "title": "Status", + "type": "string" + }, + "streams": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/StreamHealth" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Streams" + }, + "updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "required": [ + "id", + "organization_id", + "provider", + "status", + "masked_key" + ], + "title": "ConnectorRead", + "type": "object" + }, + "ConnectorUpdate": { + "properties": { + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + }, + "status": { + "anyOf": [ + { + "enum": [ + "active", + "paused" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + "title": "ConnectorUpdate", + "type": "object" + }, + "Content": { + "additionalProperties": false, + "description": "Contains the multi-part content of a message.", + "properties": { + "parts": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Part" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "List of parts that constitute a single message. Each part may have\n a different IANA MIME type.", + "title": "Parts" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The producer of the content. Must be either 'user' or 'model'. If not set, the service will default to 'user'.", + "title": "Role" + } + }, + "title": "Content", + "type": "object" + }, + "CountTokensRequest": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "CountTokensRequest", + "type": "object" + }, + "CreatedApiKey": { + "properties": { + "allowed_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Allowed Models" + }, + "cost_center": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Center" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "is_active": { + "title": "Is Active", + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "plaintext": { + "title": "Plaintext", + "type": "string" + }, + "prefix": { + "title": "Prefix", + "type": "string" + }, + "team": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team" + }, + "team_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Id" + }, + "tier": { + "title": "Tier", + "type": "string" + } + }, + "required": [ + "id", + "name", + "prefix", + "tier", + "created_at", + "is_active", + "expires_at", + "cost_center", + "team", + "team_id", + "allowed_models", + "plaintext" + ], + "title": "CreatedApiKey", + "type": "object" + }, + "CreatedTeamInvite": { + "properties": { + "accepted_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accepted At" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "revoked_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + }, + "role": { + "enum": [ + "owner", + "admin", + "member", + "auditor" + ], + "title": "Role", + "type": "string" + }, + "token": { + "title": "Token", + "type": "string" + } + }, + "required": [ + "id", + "email", + "role", + "expires_at", + "accepted_at", + "revoked_at", + "created_at", + "token" + ], + "title": "CreatedTeamInvite", + "type": "object" + }, + "DailyUsageView": { + "properties": { + "completion_tokens": { + "title": "Completion Tokens", + "type": "integer" + }, + "cost_complete": { + "default": true, + "title": "Cost Complete", + "type": "boolean" + }, + "cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Cost Usd" + }, + "date": { + "format": "date", + "title": "Date", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "prompt_tokens": { + "title": "Prompt Tokens", + "type": "integer" + }, + "request_count": { + "title": "Request Count", + "type": "integer" + }, + "unpriced_requests": { + "default": 0, + "title": "Unpriced Requests", + "type": "integer" + } + }, + "required": [ + "date", + "model", + "request_count", + "prompt_tokens", + "completion_tokens", + "cost_usd" + ], + "title": "DailyUsageView", + "type": "object" + }, + "Delivery": { + "description": "Delivery mode for the generated content.", + "enum": [ + "DELIVERY_UNSPECIFIED", + "INLINE", + "URI" + ], + "title": "Delivery", + "type": "string" + }, + "DetectiveOverview": { + "properties": { + "by_entity_type": { + "additionalProperties": { + "type": "integer" + }, + "title": "By Entity Type", + "type": "object" + }, + "by_kvkk_category": { + "additionalProperties": { + "type": "integer" + }, + "title": "By Kvkk Category", + "type": "object" + }, + "by_severity": { + "additionalProperties": { + "type": "integer" + }, + "title": "By Severity", + "type": "object" + }, + "total_findings": { + "minimum": 0.0, + "title": "Total Findings", + "type": "integer" + } + }, + "required": [ + "total_findings", + "by_severity", + "by_entity_type", + "by_kvkk_category" + ], + "title": "DetectiveOverview", + "type": "object" + }, + "DynamicRetrievalConfig": { + "additionalProperties": false, + "description": "Describes the options to customize dynamic retrieval.", + "properties": { + "dynamicThreshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. The threshold to be used in dynamic retrieval. If not set, a system default value is used.", + "title": "Dynamicthreshold" + }, + "mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/DynamicRetrievalConfigMode" + }, + { + "type": "null" + } + ], + "description": "The mode of the predictor to be used in dynamic retrieval." + } + }, + "title": "DynamicRetrievalConfig", + "type": "object" + }, + "DynamicRetrievalConfigMode": { + "description": "The mode of the predictor to be used in dynamic retrieval.", + "enum": [ + "MODE_UNSPECIFIED", + "MODE_DYNAMIC" + ], + "title": "DynamicRetrievalConfigMode", + "type": "string" + }, + "EnterpriseWebSearch": { + "additionalProperties": false, + "description": "Tool to search public web data, powered by Vertex AI Search and Sec4 compliance.\n\nThis data type is not supported in Gemini API.", + "properties": { + "blockingConfidence": { + "anyOf": [ + { + "$ref": "#/components/schemas/PhishBlockThreshold" + }, + { + "type": "null" + } + ], + "description": "Optional. Sites with confidence level chosen & above this value will be blocked from the search results." + }, + "excludeDomains": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. List of domains to be excluded from the search results. The default limit is 2000 domains.", + "title": "Excludedomains" + } + }, + "title": "EnterpriseWebSearch", + "type": "object" + }, + "EntityFound": { + "properties": { + "end": { + "title": "End", + "type": "integer" + }, + "score": { + "title": "Score", + "type": "number" + }, + "start": { + "title": "Start", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "score", + "start", + "end" + ], + "title": "EntityFound", + "type": "object" + }, + "Environment": { + "description": "The environment being operated.", + "enum": [ + "ENVIRONMENT_UNSPECIFIED", + "ENVIRONMENT_BROWSER", + "ENVIRONMENT_MOBILE", + "ENVIRONMENT_DESKTOP" + ], + "title": "Environment", + "type": "string" + }, + "ExecutableCode": { + "additionalProperties": false, + "description": "Code generated by the model that is meant to be executed, and the result returned to the model.\n\nGenerated when using the `CodeExecution` tool, in which the code will be\nautomatically executed, and a corresponding CodeExecutionResult will also be\ngenerated.", + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The code to be executed.", + "title": "Code" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Unique identifier of the `ExecutableCode` part. The server returns the `CodeExecutionResult` with the matching `id`. This field is not supported in Vertex AI.", + "title": "Id" + }, + "language": { + "anyOf": [ + { + "$ref": "#/components/schemas/Language" + }, + { + "type": "null" + } + ], + "description": "Required. Programming language of the `code`." + } + }, + "title": "ExecutableCode", + "type": "object" + }, + "ExternalApi": { + "additionalProperties": false, + "description": "Retrieve from data source powered by external API for grounding.\n\nThe external API is not owned by Google, but need to follow the pre-defined\nAPI spec. This data type is not supported in Gemini API.", + "properties": { + "apiAuth": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiAuth" + }, + { + "type": "null" + } + ], + "description": "The authentication config to access the API. Deprecated. Please use auth_config instead." + }, + "apiSpec": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApiSpec" + }, + { + "type": "null" + } + ], + "description": "The API spec that the external API implements." + }, + "authConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthConfig" + }, + { + "type": "null" + } + ], + "description": "The authentication config to access the API." + }, + "elasticSearchParams": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalApiElasticSearchParams" + }, + { + "type": "null" + } + ], + "description": "Parameters for the elastic search API." + }, + "endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The endpoint of the external API. The system will call the API at this endpoint to retrieve the data for grounding. Example: https://acme.com:443/search", + "title": "Endpoint" + }, + "simpleSearchParams": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalApiSimpleSearchParams" + }, + { + "type": "null" + } + ], + "description": "Parameters for the simple search API." + } + }, + "title": "ExternalApi", + "type": "object" + }, + "ExternalApiElasticSearchParams": { + "additionalProperties": false, + "description": "The search parameters to use for the ELASTIC_SEARCH spec.\n\nThis data type is not supported in Gemini API.", + "properties": { + "index": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The ElasticSearch index to use.", + "title": "Index" + }, + "numHits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. Number of hits (chunks) to request. When specified, it is passed to Elasticsearch as the `num_hits` param.", + "title": "Numhits" + }, + "searchTemplate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The ElasticSearch search template to use.", + "title": "Searchtemplate" + } + }, + "title": "ExternalApiElasticSearchParams", + "type": "object" + }, + "ExternalApiSimpleSearchParams": { + "additionalProperties": false, + "description": "The search parameters to use for SIMPLE_SEARCH spec.\n\nThis data type is not supported in Gemini API.", + "properties": {}, + "title": "ExternalApiSimpleSearchParams", + "type": "object" + }, + "FeatureSelectionPreference": { + "description": "Options for feature selection preference.", + "enum": [ + "FEATURE_SELECTION_PREFERENCE_UNSPECIFIED", + "PRIORITIZE_QUALITY", + "BALANCED", + "PRIORITIZE_COST" + ], + "title": "FeatureSelectionPreference", + "type": "string" + }, + "FileData": { + "additionalProperties": false, + "description": "URI-based data.\n\nA FileData message contains a URI pointing to data of a specific media type.\nIt is used to represent images, audio, and video stored in Google Cloud\nStorage.", + "properties": { + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The display name of the file. Used to provide a label or filename to distinguish files. This field is only returned in `PromptMessage` for prompt management. It is used in the Gemini calls only when server side tools (`code_execution`, `google_search`, and `url_context`) are enabled. This field is not supported in Gemini API.", + "title": "Displayname" + }, + "fileUri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The URI of the file in Google Cloud Storage.", + "title": "Fileuri" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The IANA standard MIME type of the source data.", + "title": "Mimetype" + } + }, + "title": "FileData", + "type": "object" + }, + "FileSearch": { + "additionalProperties": false, + "description": "The FileSearch tool that retrieves knowledge from Semantic Retrieval corpora.\n\nFiles are imported to Semantic Retrieval corpora using the ImportFile API.\nThis data type is not supported in Vertex AI.", + "properties": { + "fileSearchStoreNames": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Required. The names of the file_search_stores to retrieve from. Example: `fileSearchStores/my-file-search-store-123`", + "title": "Filesearchstorenames" + }, + "metadataFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Metadata filter to apply to the semantic retrieval documents and chunks.", + "title": "Metadatafilter" + }, + "topK": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. The number of semantic retrieval chunks to retrieve.", + "title": "Topk" + } + }, + "title": "FileSearch", + "type": "object" + }, + "FindingPage": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/FindingRead" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "FindingPage", + "type": "object" + }, + "FindingRead": { + "properties": { + "activity_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Activity Id" + }, + "actor_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actor Email" + }, + "connector_id": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + }, + "content_id": { + "title": "Content Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "entity_type": { + "title": "Entity Type", + "type": "string" + }, + "gdpr_category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gdpr Category" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "kvkk_category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Kvkk Category" + }, + "match_length": { + "title": "Match Length", + "type": "integer" + }, + "match_offset": { + "title": "Match Offset", + "type": "integer" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "occurred_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Occurred At" + }, + "severity": { + "title": "Severity", + "type": "string" + } + }, + "required": [ + "id", + "connector_id", + "content_id", + "entity_type", + "severity", + "match_offset", + "match_length" + ], + "title": "FindingRead", + "type": "object" + }, + "FindingSummary": { + "properties": { + "by_entity_type": { + "additionalProperties": { + "type": "integer" + }, + "title": "By Entity Type", + "type": "object" + }, + "by_kvkk_category": { + "additionalProperties": { + "type": "integer" + }, + "title": "By Kvkk Category", + "type": "object" + }, + "by_severity": { + "additionalProperties": { + "type": "integer" + }, + "title": "By Severity", + "type": "object" + }, + "top_actors": { + "items": { + "$ref": "#/components/schemas/TopActor" + }, + "title": "Top Actors", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "total", + "by_severity", + "by_entity_type", + "by_kvkk_category", + "top_actors" + ], + "title": "FindingSummary", + "type": "object" + }, + "ForwardTargetCreate": { + "properties": { + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "endpoint": { + "minLength": 1, + "title": "Endpoint", + "type": "string" + }, + "kind": { + "default": "siem_webhook", + "enum": [ + "siem_webhook", + "slack", + "email" + ], + "title": "Kind", + "type": "string" + }, + "min_severity": { + "default": "high", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "title": "Min Severity", + "type": "string" + }, + "secret": { + "anyOf": [ + { + "minLength": 16, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Secret" + } + }, + "required": [ + "endpoint" + ], + "title": "ForwardTargetCreate", + "type": "object" + }, + "ForwardTargetRead": { + "properties": { + "connector_id": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "endpoint_origin": { + "title": "Endpoint Origin", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "siem_webhook", + "slack", + "email" + ], + "title": "Kind", + "type": "string" + }, + "min_severity": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "title": "Min Severity", + "type": "string" + }, + "signed": { + "title": "Signed", + "type": "boolean" + }, + "updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "required": [ + "id", + "connector_id", + "kind", + "endpoint_origin", + "signed", + "min_severity", + "enabled" + ], + "title": "ForwardTargetRead", + "type": "object" + }, + "ForwardTargetUpdate": { + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "endpoint": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Endpoint" + }, + "min_severity": { + "anyOf": [ + { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Min Severity" + }, + "secret": { + "anyOf": [ + { + "minLength": 16, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Secret" + } + }, + "title": "ForwardTargetUpdate", + "type": "object" + }, + "FunctionCall": { + "additionalProperties": false, + "description": "A predicted FunctionCall returned from the model that contains a string representing the FunctionDeclaration.name and a structured JSON object containing the parameters and their values.", + "properties": { + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional. The function parameters and values in JSON object format. See FunctionDeclaration.parameters for parameter details.", + "title": "Args" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The unique id of the function call. If populated, the client to execute the `function_call` and return the response with the matching `id`.", + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The name of the function to call. Matches FunctionDeclaration.name.", + "title": "Name" + }, + "partialArgs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PartialArg" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. The partial argument value of the function call. If provided, represents the arguments/fields that are streamed incrementally. This field is not supported in Gemini API.", + "title": "Partialargs" + }, + "willContinue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Whether this is the last part of the FunctionCall. If true, another partial message for the current FunctionCall is expected to follow. This field is not supported in Gemini API.", + "title": "Willcontinue" + } + }, + "title": "FunctionCall", + "type": "object" + }, + "FunctionCallingConfig": { + "additionalProperties": false, + "description": "Function calling config.", + "properties": { + "allowedFunctionNames": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. Function names to call. Only set when the Mode is ANY. Function names should match FunctionDeclaration.name. With mode set to ANY, model will predict a function call from the set of function names provided.", + "title": "Allowedfunctionnames" + }, + "mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionCallingConfigMode" + }, + { + "type": "null" + } + ], + "description": "Optional. Function calling mode." + }, + "streamFunctionCallArguments": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. When set to true, arguments of a single function call will be streamed out in multiple parts/contents/responses. Partial parameter results will be returned in the `FunctionCall.partial_args` field. This field is not supported in Gemini API.", + "title": "Streamfunctioncallarguments" + } + }, + "title": "FunctionCallingConfig", + "type": "object" + }, + "FunctionCallingConfigMode": { + "description": "Function calling mode.", + "enum": [ + "MODE_UNSPECIFIED", + "AUTO", + "ANY", + "NONE", + "VALIDATED" + ], + "title": "FunctionCallingConfigMode", + "type": "string" + }, + "FunctionDeclaration": { + "additionalProperties": false, + "description": "Structured representation of a function declaration as defined by the [OpenAPI 3.0 specification](https://spec.openapis.org/oas/v3.0.3).\n\nIncluded in this declaration are the function name, description, parameters\nand response type. This FunctionDeclaration is a representation of a block of\ncode that can be used as a `Tool` by the model and executed by the client.", + "properties": { + "behavior": { + "anyOf": [ + { + "$ref": "#/components/schemas/Behavior" + }, + { + "type": "null" + } + ], + "description": "Optional. Specifies the function Behavior. If not specified, the system keeps the current function call behavior. This field is currently only supported by the BidiGenerateContent method." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Description and purpose of the function. Model uses it to decide how and whether to call the function.", + "title": "Description" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The name of the function to call. Must start with a letter or an underscore. Must be a-z, A-Z, 0-9, or contain underscores, dots, colons and dashes, with a maximum length of 128.", + "title": "Name" + }, + "parameters": { + "anyOf": [ + { + "$ref": "#/components/schemas/Schema" + }, + { + "type": "null" + } + ], + "description": "Optional. Describes the parameters to this function in JSON Schema Object format. Reflects the Open API 3.03 Parameter Object. string Key: the name of the parameter. Parameter names are case sensitive. Schema Value: the Schema defining the type used for the parameter. For function with no parameters, this can be left unset. Parameter names must start with a letter or an underscore and must only contain chars a-z, A-Z, 0-9, or underscores with a maximum length of 64. Example with 1 required and 1 optional parameter: type: OBJECT properties: param1: type: STRING param2: type: INTEGER required: - param1" + }, + "parametersJsonSchema": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Optional. Describes the parameters to the function in JSON Schema format. The schema must describe an object where the properties are the parameters to the function. For example: ``` { \"type\": \"object\", \"properties\": { \"name\": { \"type\": \"string\" }, \"age\": { \"type\": \"integer\" } }, \"additionalProperties\": false, \"required\": [\"name\", \"age\"], \"propertyOrdering\": [\"name\", \"age\"] } ``` This field is mutually exclusive with `parameters`.", + "title": "Parametersjsonschema" + }, + "response": { + "anyOf": [ + { + "$ref": "#/components/schemas/Schema" + }, + { + "type": "null" + } + ], + "description": "Optional. Describes the output from this function in JSON Schema format. Reflects the Open API 3.03 Response Object. The Schema defines the type used for the response value of the function." + }, + "responseJsonSchema": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Optional. Describes the output from this function in JSON Schema format. The value specified by the schema is the response value of the function. This field is mutually exclusive with `response`.", + "title": "Responsejsonschema" + } + }, + "title": "FunctionDeclaration", + "type": "object" + }, + "FunctionResponse": { + "additionalProperties": false, + "description": "The result output from a FunctionCall that contains a string representing the FunctionDeclaration.name and a structured JSON object containing any output from the function is used as context to the model.\n\nThis should contain the result of a `FunctionCall` made based on model\nprediction.", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The id of the function call this response is for. Populated by the client to match the corresponding function call `id`.", + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The name of the function to call. Matches FunctionDeclaration.name and FunctionCall.name.", + "title": "Name" + }, + "parts": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/FunctionResponsePart" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. Ordered `Parts` that constitute a function response. Parts may have different IANA MIME types.", + "title": "Parts" + }, + "response": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Required. The function response in JSON object format. Use \"output\" key to specify function output and \"error\" key to specify error details (if any). If \"output\" and \"error\" keys are not specified, then whole \"response\" is treated as function output.", + "title": "Response" + }, + "scheduling": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionResponseScheduling" + }, + { + "type": "null" + } + ], + "description": "Optional. Specifies how the response should be scheduled in the conversation. Only applicable to NON_BLOCKING function calls, is ignored otherwise. Defaults to WHEN_IDLE." + }, + "willContinue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Signals that function call continues, and more responses will be returned, turning the function call into a generator. Is only applicable to NON_BLOCKING function calls, is ignored otherwise. If set to false, future responses will not be considered. It is allowed to return empty `response` with `will_continue=False` to signal that the function call is finished. This may still trigger the model generation. To avoid triggering the generation and finish the function call, additionally set `scheduling` to `SILENT`. This field is not supported in Vertex AI.", + "title": "Willcontinue" + } + }, + "title": "FunctionResponse", + "type": "object" + }, + "FunctionResponseBlob": { + "additionalProperties": false, + "description": "Raw media bytes for function response.\n\nText should not be sent as raw bytes, use the 'text' field.", + "properties": { + "data": { + "anyOf": [ + { + "contentEncoding": "base64", + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. Raw bytes.", + "title": "Data" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Display name of the blob. Used to provide a label or filename to distinguish blobs. This field is only returned in PromptMessage for prompt management. It is currently used in the Gemini GenerateContent calls only when server side tools (code_execution, google_search, and url_context) are enabled. This field is not supported in Gemini API.", + "title": "Displayname" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The IANA standard MIME type of the source data.", + "title": "Mimetype" + } + }, + "title": "FunctionResponseBlob", + "type": "object" + }, + "FunctionResponseFileData": { + "additionalProperties": false, + "description": "URI based data for function response.\n\nThis data type is not supported in Gemini API.", + "properties": { + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Display name of the file data. Used to provide a label or filename to distinguish file datas. This field is only returned in PromptMessage for prompt management. It is currently used in the Gemini GenerateContent calls only when server side tools (code_execution, google_search, and url_context) are enabled.", + "title": "Displayname" + }, + "fileUri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. URI.", + "title": "Fileuri" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The IANA standard MIME type of the source data.", + "title": "Mimetype" + } + }, + "title": "FunctionResponseFileData", + "type": "object" + }, + "FunctionResponsePart": { + "additionalProperties": false, + "description": "A datatype containing media that is part of a `FunctionResponse` message.\n\nA `FunctionResponsePart` consists of data which has an associated datatype. A\n`FunctionResponsePart` can only contain one of the accepted types in\n`FunctionResponsePart.data`. A `FunctionResponsePart` must have a fixed IANA\nMIME type identifying the type and subtype of the media if the `inline_data`\nfield is filled with raw bytes.", + "properties": { + "fileData": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionResponseFileData" + }, + { + "type": "null" + } + ], + "description": "URI based data. This field is not supported in Gemini API." + }, + "inlineData": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionResponseBlob" + }, + { + "type": "null" + } + ], + "description": "Inline media bytes." + } + }, + "title": "FunctionResponsePart", + "type": "object" + }, + "FunctionResponseScheduling": { + "description": "Specifies how the response should be scheduled in the conversation.\n\nOnly applicable to NON_BLOCKING function calls, is ignored otherwise. Defaults\nto WHEN_IDLE.", + "enum": [ + "SCHEDULING_UNSPECIFIED", + "SILENT", + "WHEN_IDLE", + "INTERRUPT" + ], + "title": "FunctionResponseScheduling", + "type": "string" + }, + "GenerateContentRequest": { + "additionalProperties": false, + "properties": { + "cachedContent": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cachedcontent" + }, + "contents": { + "items": { + "$ref": "#/components/schemas/Content" + }, + "maxItems": 100000, + "minItems": 1, + "title": "Contents", + "type": "array" + }, + "generationConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/GenerationConfig" + }, + { + "type": "null" + } + ] + }, + "safetySettings": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SafetySetting" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Safetysettings" + }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/components/schemas/ServiceTier" + }, + { + "type": "null" + } + ] + }, + "store": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Store" + }, + "systemInstruction": { + "anyOf": [ + { + "$ref": "#/components/schemas/Content" + }, + { + "type": "null" + } + ] + }, + "toolConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolConfig" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Tool" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tools" + } + }, + "required": [ + "contents" + ], + "title": "GenerateContentRequest", + "type": "object" + }, + "GenerationConfig": { + "additionalProperties": false, + "description": "Generation config.", + "properties": { + "audioTimestamp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. If enabled, audio timestamps will be included in the request to the model. This can be useful for synchronizing audio with other modalities in the response. This field is not supported in Gemini API.", + "title": "Audiotimestamp" + }, + "audioTranscriptionConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/AudioTranscriptionConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Configuration for audio transcription (speech recognition).\n " + }, + "candidateCount": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. The number of candidate responses to generate. A higher `candidate_count` can provide more options to choose from, but it also consumes more resources. This can be useful for generating a variety of responses and selecting the best one.", + "title": "Candidatecount" + }, + "enableAffectiveDialog": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. If enabled, the model will detect emotions and adapt its responses accordingly. For example, if the model detects that the user is frustrated, it may provide a more empathetic response. This field is not supported in Gemini API.", + "title": "Enableaffectivedialog" + }, + "enableEnhancedCivicAnswers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Enables enhanced civic answers. It may not be available for all models. This field is not supported in Vertex AI.", + "title": "Enableenhancedcivicanswers" + }, + "frequencyPenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Penalizes tokens based on their frequency in the generated text. A positive value helps to reduce the repetition of words and phrases. Valid values can range from [-2.0, 2.0].", + "title": "Frequencypenalty" + }, + "logprobs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. The number of top log probabilities to return for each token. This can be used to see which other tokens were considered likely candidates for a given position. A higher value will return more options, but it will also increase the size of the response.", + "title": "Logprobs" + }, + "maxOutputTokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. The maximum number of tokens to generate in the response. A token is approximately four characters. The default value varies by model. This parameter can be used to control the length of the generated text and prevent overly long responses.", + "title": "Maxoutputtokens" + }, + "mediaResolution": { + "anyOf": [ + { + "$ref": "#/components/schemas/MediaResolution" + }, + { + "type": "null" + } + ], + "description": "Optional. The token resolution at which input media content is sampled. This is used to control the trade-off between the quality of the response and the number of tokens used to represent the media. A higher resolution allows the model to perceive more detail, which can lead to a more nuanced response, but it will also use more tokens. This does not affect the image dimensions sent to the model." + }, + "modelSelectionConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelSelectionConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Config for model selection." + }, + "presencePenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Penalizes tokens that have already appeared in the generated text. A positive value encourages the model to generate more diverse and less repetitive text. Valid values can range from [-2.0, 2.0].", + "title": "Presencepenalty" + }, + "responseFormat": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ResponseFormat" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. New response format field for the model to configure output formatting and delivery.", + "title": "Responseformat" + }, + "responseJsonSchema": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Output schema of the generated response. This is an alternative to\n `response_schema` that accepts [JSON Schema](https://json-schema.org/).\n ", + "title": "Responsejsonschema" + }, + "responseLogprobs": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. If set to true, the log probabilities of the output tokens are returned. Log probabilities are the logarithm of the probability of a token appearing in the output. A higher log probability means the token is more likely to be generated. This can be useful for analyzing the model's confidence in its own output and for debugging.", + "title": "Responselogprobs" + }, + "responseMimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The IANA standard MIME type of the response. The model will generate output that conforms to this MIME type. Supported values include 'text/plain' (default) and 'application/json'. The model needs to be prompted to output the appropriate response type, otherwise the behavior is undefined. Deprecated: Use `response_format` instead.", + "title": "Responsemimetype" + }, + "responseModalities": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Modality" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. The modalities of the response. The model will generate a response that includes all the specified modalities. For example, if this is set to `[TEXT, IMAGE]`, the response will include both text and an image.", + "title": "Responsemodalities" + }, + "responseSchema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Schema" + }, + { + "type": "null" + } + ], + "description": "Optional. Lets you to specify a schema for the model's response, ensuring that the output conforms to a particular structure. This is useful for generating structured data such as JSON. The schema is a subset of the [OpenAPI 3.0 schema object](https://spec.openapis.org/oas/v3.0.3#schema) object. When this field is set, you must also set the `response_mime_type` to `application/json`. Deprecated: Use `response_format` instead." + }, + "routingConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/GenerationConfigRoutingConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Routing configuration. This field is not supported in Gemini API." + }, + "seed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. A seed for the random number generator. By setting a seed, you can make the model's output mostly deterministic. For a given prompt and parameters (like temperature, top_p, etc.), the model will produce the same response every time. However, it's not a guaranteed absolute deterministic behavior. This is different from parameters like `temperature`, which control the *level* of randomness. `seed` ensures that the \"random\" choices the model makes are the same on every run, making it essential for testing and ensuring reproducible results.", + "title": "Seed" + }, + "speechConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/SpeechConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. The speech generation config." + }, + "stopSequences": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. A list of character sequences that will stop the model from generating further tokens. If a stop sequence is generated, the output will end at that point. This is useful for controlling the length and structure of the output. For example, you can use [\"\n\", \"###\"] to stop generation at a new line or a specific marker.", + "title": "Stopsequences" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Controls the randomness of the output. A higher temperature results in more creative and diverse responses, while a lower temperature makes the output more predictable and focused. The valid range is (0.0, 2.0].", + "title": "Temperature" + }, + "thinkingConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ThinkingConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Configuration for thinking features. An error will be returned if this field is set for models that don't support thinking." + }, + "topK": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Specifies the top-k sampling threshold. The model considers only the top k most probable tokens for the next token. This can be useful for generating more coherent and less random text. For example, a `top_k` of 40 means the model will choose the next word from the 40 most likely words.", + "title": "Topk" + }, + "topP": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Specifies the nucleus sampling threshold. The model considers only the smallest set of tokens whose cumulative probability is at least `top_p`. This helps generate more diverse and less repetitive responses. For example, a `top_p` of 0.9 means the model considers tokens until the cumulative probability of the tokens to select from reaches 0.9. It's recommended to adjust either temperature or `top_p`, but not both.", + "title": "Topp" + }, + "translationConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/TranslationConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Config for translation. This field is not supported in Vertex AI." + } + }, + "title": "GenerationConfig", + "type": "object" + }, + "GenerationConfigRoutingConfig": { + "additionalProperties": false, + "description": "The configuration for routing the request to a specific model.\n\nThis can be used to control which model is used for the generation, either\nautomatically or by specifying a model name. This data type is not supported\nin Gemini API.", + "properties": { + "autoMode": { + "anyOf": [ + { + "$ref": "#/components/schemas/GenerationConfigRoutingConfigAutoRoutingMode" + }, + { + "type": "null" + } + ], + "description": "In this mode, the model is selected automatically based on the content of the request." + }, + "manualMode": { + "anyOf": [ + { + "$ref": "#/components/schemas/GenerationConfigRoutingConfigManualRoutingMode" + }, + { + "type": "null" + } + ], + "description": "In this mode, the model is specified manually." + } + }, + "title": "GenerationConfigRoutingConfig", + "type": "object" + }, + "GenerationConfigRoutingConfigAutoRoutingMode": { + "additionalProperties": false, + "description": "The configuration for automated routing.\n\nWhen automated routing is specified, the routing will be determined by the\npretrained routing model and customer provided model routing preference. This\ndata type is not supported in Gemini API.", + "properties": { + "modelRoutingPreference": { + "anyOf": [ + { + "enum": [ + "UNKNOWN", + "PRIORITIZE_QUALITY", + "BALANCED", + "PRIORITIZE_COST" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The model routing preference.", + "title": "Modelroutingpreference" + } + }, + "title": "GenerationConfigRoutingConfigAutoRoutingMode", + "type": "object" + }, + "GenerationConfigRoutingConfigManualRoutingMode": { + "additionalProperties": false, + "description": "The configuration for manual routing.\n\nWhen manual routing is specified, the model will be selected based on the\nmodel name provided. This data type is not supported in Gemini API.", + "properties": { + "modelName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The name of the model to use. Only public LLM models are accepted.", + "title": "Modelname" + } + }, + "title": "GenerationConfigRoutingConfigManualRoutingMode", + "type": "object" + }, + "GoogleMaps": { + "additionalProperties": false, + "description": "Tool to retrieve knowledge from Google Maps.", + "properties": { + "authConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuthConfig" + }, + { + "type": "null" + } + ], + "description": "The authentication config to access the API. Only API key is supported. This field is not supported in Gemini API." + }, + "enableWidget": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Deprecated. The Google Maps contextual widget behavior in Grounding with Google Maps is being deprecated; this field is planned for removal and no longer has any effect once removed. Optional. Whether to return a widget context token in the GroundingMetadata of the response.", + "title": "Enablewidget" + }, + "groundingTypes": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoogleMapsGroundingTypes" + }, + { + "type": "null" + } + ], + "description": "Optional. Specifies the types of Google Maps grounding to enable. This field is not supported in Gemini API." + } + }, + "title": "GoogleMaps", + "type": "object" + }, + "GoogleMapsGroundingTypes": { + "additionalProperties": false, + "description": "Defines the types of Google Maps grounding that can be enabled and their configurations.\n\nThis data type is not supported in Gemini API.", + "properties": { + "places": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoogleMapsPlaces" + }, + { + "type": "null" + } + ], + "description": "Optional. Enables grounding with Google Maps Places. This is the default grounding type when no `GroundingTypes` are specified." + }, + "routing": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoogleMapsRouting" + }, + { + "type": "null" + } + ], + "description": "Optional. Enables grounding with Google Maps Routing APIs (ComputeRoutes and SearchAlongRoute)." + } + }, + "title": "GoogleMapsGroundingTypes", + "type": "object" + }, + "GoogleMapsPlaces": { + "additionalProperties": false, + "description": "Grounding with Google Maps Places data (e.g.\n\nQueryPlaces). This is the default Google Maps grounding type when no other\ntype is specified. This data type is not supported in Gemini API.", + "properties": {}, + "title": "GoogleMapsPlaces", + "type": "object" + }, + "GoogleMapsRouting": { + "additionalProperties": false, + "description": "Grounding with Google Maps Routing APIs (ComputeRoutes and SearchAlongRoute).\n\nThis data type is not supported in Gemini API.", + "properties": {}, + "title": "GoogleMapsRouting", + "type": "object" + }, + "GoogleSearch": { + "additionalProperties": false, + "description": "GoogleSearch tool type.\n\nTool to support Google Search in Model. Powered by Google.", + "properties": { + "blockingConfidence": { + "anyOf": [ + { + "$ref": "#/components/schemas/PhishBlockThreshold" + }, + { + "type": "null" + } + ], + "description": "Optional. Sites with confidence level chosen & above this value will be blocked from the search results. This field is not supported in Gemini API." + }, + "excludeDomains": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. List of domains to be excluded from the search results. The default limit is 2000 domains. Example: [\"amazon.com\", \"facebook.com\"]. This field is not supported in Gemini API.", + "title": "Excludedomains" + }, + "searchTypes": { + "anyOf": [ + { + "$ref": "#/components/schemas/SearchTypes" + }, + { + "type": "null" + } + ], + "description": "Optional. The set of search types to enable. If not set, web search is enabled by default." + }, + "timeRangeFilter": { + "anyOf": [ + { + "$ref": "#/components/schemas/Interval" + }, + { + "type": "null" + } + ], + "description": "Optional. Filter search results to a specific time range. If customers set a start time, they must set an end time (and vice versa). This field is not supported in Vertex AI." + } + }, + "title": "GoogleSearch", + "type": "object" + }, + "GoogleSearchRetrieval": { + "additionalProperties": false, + "description": "Tool to retrieve public web data for grounding, powered by Google.", + "properties": { + "dynamicRetrievalConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/DynamicRetrievalConfig" + }, + { + "type": "null" + } + ], + "description": "Specifies the dynamic retrieval configuration for the given source." + } + }, + "title": "GoogleSearchRetrieval", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "HarmBlockMethod": { + "description": "The method for blocking content.\n\nIf not specified, the default behavior is to use the probability score. This\nenum is not supported in Gemini API.", + "enum": [ + "HARM_BLOCK_METHOD_UNSPECIFIED", + "SEVERITY", + "PROBABILITY" + ], + "title": "HarmBlockMethod", + "type": "string" + }, + "HarmBlockThreshold": { + "description": "The threshold for blocking content.\n\nIf the harm probability exceeds this threshold, the content will be blocked.", + "enum": [ + "HARM_BLOCK_THRESHOLD_UNSPECIFIED", + "BLOCK_LOW_AND_ABOVE", + "BLOCK_MEDIUM_AND_ABOVE", + "BLOCK_ONLY_HIGH", + "BLOCK_NONE", + "OFF" + ], + "title": "HarmBlockThreshold", + "type": "string" + }, + "HarmCategory": { + "description": "The harm category to be blocked.", + "enum": [ + "HARM_CATEGORY_UNSPECIFIED", + "HARM_CATEGORY_HARASSMENT", + "HARM_CATEGORY_HATE_SPEECH", + "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "HARM_CATEGORY_DANGEROUS_CONTENT", + "HARM_CATEGORY_CIVIC_INTEGRITY", + "HARM_CATEGORY_IMAGE_HATE", + "HARM_CATEGORY_IMAGE_DANGEROUS_CONTENT", + "HARM_CATEGORY_IMAGE_HARASSMENT", + "HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT", + "HARM_CATEGORY_JAILBREAK" + ], + "title": "HarmCategory", + "type": "string" + }, + "HttpElementLocation": { + "description": "The location of the API key. This enum is not supported in Gemini API.", + "enum": [ + "HTTP_IN_UNSPECIFIED", + "HTTP_IN_QUERY", + "HTTP_IN_HEADER", + "HTTP_IN_PATH", + "HTTP_IN_BODY", + "HTTP_IN_COOKIE" + ], + "title": "HttpElementLocation", + "type": "string" + }, + "ImageResponseFormat": { + "additionalProperties": false, + "description": "Configuration for image-specific output formatting.", + "properties": { + "aspectRatio": { + "anyOf": [ + { + "$ref": "#/components/schemas/AspectRatio" + }, + { + "type": "null" + } + ], + "description": "Optional. The aspect ratio for the image output." + }, + "delivery": { + "anyOf": [ + { + "$ref": "#/components/schemas/Delivery" + }, + { + "type": "null" + } + ], + "description": "Optional. Delivery mode for the generated content." + }, + "imageSize": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageSize" + }, + { + "type": "null" + } + ], + "description": "Optional. The size of the image output." + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The MIME type of the image output.", + "title": "Mimetype" + } + }, + "title": "ImageResponseFormat", + "type": "object" + }, + "ImageSearch": { + "additionalProperties": false, + "description": "Image search for grounding and related configurations.", + "properties": {}, + "title": "ImageSearch", + "type": "object" + }, + "ImageSize": { + "description": "The size of the image output.", + "enum": [ + "IMAGE_SIZE_UNSPECIFIED", + "IMAGE_SIZE_FIVE_TWELVE", + "IMAGE_SIZE_ONE_K", + "IMAGE_SIZE_TWO_K", + "IMAGE_SIZE_FOUR_K" + ], + "title": "ImageSize", + "type": "string" + }, + "Interval": { + "additionalProperties": false, + "description": "Represents a time interval, encoded as a Timestamp start (inclusive) and a Timestamp end (exclusive).\n\nThe start must be less than or equal to the end. When the start equals the\nend, the interval is empty (matches no time). When both start and end are\nunspecified, the interval matches any time.", + "properties": { + "endTime": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Exclusive end of the interval. If specified, a Timestamp matching this interval will have to be before the end.", + "title": "Endtime" + }, + "startTime": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Inclusive start of the interval. If specified, a Timestamp matching this interval will have to be the same or after the start.", + "title": "Starttime" + } + }, + "title": "Interval", + "type": "object" + }, + "JsonValue": {}, + "Language": { + "description": "Programming language of the `code`.", + "enum": [ + "LANGUAGE_UNSPECIFIED", + "PYTHON" + ], + "title": "Language", + "type": "string" + }, + "LanguageAuto": { + "additionalProperties": false, + "description": "Deprecated: Language auto-detection is now the default when language_codes is omitted.", + "properties": {}, + "title": "LanguageAuto", + "type": "object" + }, + "LanguageHints": { + "additionalProperties": false, + "description": "Deprecated: Use AudioTranscriptionConfig.language_codes instead.", + "properties": { + "languageCodes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Deprecated. BCP-47 language codes.", + "title": "Languagecodes" + } + }, + "title": "LanguageHints", + "type": "object" + }, + "LatLng": { + "additionalProperties": false, + "description": "An object that represents a latitude/longitude pair.\n\nThis is expressed as a pair of doubles to represent degrees latitude and\ndegrees longitude. Unless specified otherwise, this object must conform to the\nWGS84 standard. Values must be within normalized ranges.", + "properties": { + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "The latitude in degrees. It must be in the range [-90.0, +90.0].", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "The longitude in degrees. It must be in the range [-180.0, +180.0].", + "title": "Longitude" + } + }, + "title": "LatLng", + "type": "object" + }, + "McpServer": { + "additionalProperties": false, + "description": "A MCPServer is a server that can be called by the model to perform actions.\n\nIt is a server that implements the MCP protocol. Next ID: 6. This data type is\nnot supported in Vertex AI.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The name of the MCPServer.", + "title": "Name" + }, + "streamableHttpTransport": { + "anyOf": [ + { + "$ref": "#/components/schemas/StreamableHttpTransport" + }, + { + "type": "null" + } + ], + "description": "A transport that can stream HTTP requests and responses." + } + }, + "title": "McpServer", + "type": "object" + }, + "MediaResolution": { + "description": "The media resolution to use.", + "enum": [ + "MEDIA_RESOLUTION_UNSPECIFIED", + "MEDIA_RESOLUTION_LOW", + "MEDIA_RESOLUTION_MEDIUM", + "MEDIA_RESOLUTION_HIGH" + ], + "title": "MediaResolution", + "type": "string" + }, + "MembershipInput": { + "properties": { + "role": { + "default": "member", + "enum": [ + "member", + "team_admin" + ], + "title": "Role", + "type": "string" + } + }, + "title": "MembershipInput", + "type": "object" + }, + "MembershipView": { + "properties": { + "role": { + "default": "member", + "enum": [ + "member", + "team_admin" + ], + "title": "Role", + "type": "string" + }, + "source": { + "enum": [ + "local", + "oidc" + ], + "title": "Source", + "type": "string" + }, + "team_id": { + "format": "uuid", + "title": "Team Id", + "type": "string" + }, + "user_id": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + }, + "required": [ + "user_id", + "team_id", + "source" + ], + "title": "MembershipView", + "type": "object" + }, + "MessagesRequest": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "MessagesRequest", + "type": "object" + }, + "Modality": { + "description": "Server content modalities.", + "enum": [ + "MODALITY_UNSPECIFIED", + "TEXT", + "IMAGE", + "AUDIO", + "VIDEO" + ], + "title": "Modality", + "type": "string" + }, + "ModelDeploymentInput": { + "additionalProperties": false, + "properties": { + "alias": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]*$", + "title": "Alias", + "type": "string" + }, + "base_url": { + "maxLength": 2048, + "minLength": 1, + "title": "Base Url", + "type": "string" + }, + "declared_version": { + "maxLength": 200, + "minLength": 1, + "title": "Declared Version", + "type": "string" + }, + "deployment_kind": { + "enum": [ + "internal", + "external" + ], + "title": "Deployment Kind", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "owner": { + "maxLength": 200, + "minLength": 1, + "title": "Owner", + "type": "string" + }, + "provider": { + "enum": [ + "openai", + "anthropic" + ], + "title": "Provider", + "type": "string" + }, + "provider_secret_id": { + "format": "uuid", + "title": "Provider Secret Id", + "type": "string" + }, + "timeout_seconds": { + "default": 60, + "maximum": 300.0, + "minimum": 1.0, + "title": "Timeout Seconds", + "type": "integer" + }, + "upstream_model": { + "maxLength": 200, + "minLength": 1, + "title": "Upstream Model", + "type": "string" + } + }, + "required": [ + "alias", + "provider", + "upstream_model", + "base_url", + "provider_secret_id", + "deployment_kind", + "declared_version", + "owner" + ], + "title": "ModelDeploymentInput", + "type": "object" + }, + "ModelDeploymentView": { + "additionalProperties": false, + "properties": { + "alias": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]*$", + "title": "Alias", + "type": "string" + }, + "base_url": { + "maxLength": 2048, + "minLength": 1, + "title": "Base Url", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "declared_version": { + "maxLength": 200, + "minLength": 1, + "title": "Declared Version", + "type": "string" + }, + "deployment_kind": { + "enum": [ + "internal", + "external" + ], + "title": "Deployment Kind", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "health": { + "enum": [ + "unknown", + "healthy", + "unhealthy" + ], + "title": "Health", + "type": "string" + }, + "health_checked_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Health Checked At" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "owner": { + "maxLength": 200, + "minLength": 1, + "title": "Owner", + "type": "string" + }, + "provider": { + "enum": [ + "openai", + "anthropic" + ], + "title": "Provider", + "type": "string" + }, + "provider_secret_id": { + "format": "uuid", + "title": "Provider Secret Id", + "type": "string" + }, + "timeout_seconds": { + "default": 60, + "maximum": 300.0, + "minimum": 1.0, + "title": "Timeout Seconds", + "type": "integer" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "upstream_model": { + "maxLength": 200, + "minLength": 1, + "title": "Upstream Model", + "type": "string" + } + }, + "required": [ + "alias", + "provider", + "upstream_model", + "base_url", + "provider_secret_id", + "deployment_kind", + "declared_version", + "owner", + "id", + "health", + "health_checked_at", + "created_at", + "updated_at" + ], + "title": "ModelDeploymentView", + "type": "object" + }, + "ModelListView": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ModelRecordView" + }, + "title": "Data", + "type": "array" + }, + "object": { + "title": "Object", + "type": "string" + } + }, + "required": [ + "object", + "data" + ], + "title": "ModelListView", + "type": "object" + }, + "ModelRecordView": { + "properties": { + "created": { + "title": "Created", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "object": { + "title": "Object", + "type": "string" + }, + "owned_by": { + "title": "Owned By", + "type": "string" + } + }, + "required": [ + "id", + "object", + "created", + "owned_by" + ], + "title": "ModelRecordView", + "type": "object" + }, + "ModelSelectionConfig": { + "additionalProperties": false, + "description": "Config for model selection.", + "properties": { + "featureSelectionPreference": { + "anyOf": [ + { + "$ref": "#/components/schemas/FeatureSelectionPreference" + }, + { + "type": "null" + } + ], + "description": "Options for feature selection preference." + } + }, + "title": "ModelSelectionConfig", + "type": "object" + }, + "MultiSpeakerVoiceConfig": { + "additionalProperties": false, + "description": "Configuration for a multi-speaker text-to-speech request.", + "properties": { + "speakerVoiceConfigs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SpeakerVoiceConfig" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Required. A list of configurations for the voices of the speakers. Exactly two speaker voice configurations must be provided.", + "title": "Speakervoiceconfigs" + } + }, + "title": "MultiSpeakerVoiceConfig", + "type": "object" + }, + "NotificationTargetInput": { + "properties": { + "endpoint": { + "maxLength": 2048, + "minLength": 1, + "title": "Endpoint", + "type": "string" + }, + "kind": { + "enum": [ + "slack", + "webhook" + ], + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "endpoint" + ], + "title": "NotificationTargetInput", + "type": "object" + }, + "NotificationTargetView": { + "properties": { + "endpoint_origin": { + "title": "Endpoint Origin", + "type": "string" + }, + "kind": { + "enum": [ + "slack", + "webhook" + ], + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "endpoint_origin" + ], + "title": "NotificationTargetView", + "type": "object" + }, + "OpenAIErrorDetail": { + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "message": { + "title": "Message", + "type": "string" + }, + "param": { + "anyOf": [ + { + "$ref": "#/components/schemas/JsonValue" + }, + { + "type": "null" + } + ] + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "OpenAIErrorDetail", + "type": "object" + }, + "OpenAIErrorResponse": { + "properties": { + "error": { + "$ref": "#/components/schemas/OpenAIErrorDetail" + } + }, + "required": [ + "error" + ], + "title": "OpenAIErrorResponse", + "type": "object" + }, + "OperationRequest": { + "additionalProperties": false, + "properties": { + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id" + ], + "title": "OperationRequest", + "type": "object" + }, + "Outcome": { + "description": "Outcome of the code execution.", + "enum": [ + "OUTCOME_UNSPECIFIED", + "OUTCOME_OK", + "OUTCOME_FAILED", + "OUTCOME_DEADLINE_EXCEEDED" + ], + "title": "Outcome", + "type": "string" + }, + "OversightDecision": { + "properties": { + "decision": { + "enum": [ + "approve", + "reject" + ], + "title": "Decision", + "type": "string" + }, + "note": { + "anyOf": [ + { + "maxLength": 2000, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "required": [ + "decision" + ], + "title": "OversightDecision", + "type": "object" + }, + "OversightPolicyCreate": { + "properties": { + "default_on_timeout": { + "default": "allow", + "enum": [ + "allow", + "deny" + ], + "title": "Default On Timeout", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "mode": { + "const": "flag", + "default": "flag", + "title": "Mode", + "type": "string" + }, + "name": { + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "trigger": { + "additionalProperties": true, + "minProperties": 1, + "title": "Trigger", + "type": "object" + }, + "ttl_seconds": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ttl Seconds" + } + }, + "required": [ + "name", + "trigger" + ], + "title": "OversightPolicyCreate", + "type": "object" + }, + "OversightPolicyRead": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "default_on_timeout": { + "enum": [ + "allow", + "deny" + ], + "title": "Default On Timeout", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "mode": { + "const": "flag", + "title": "Mode", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "trigger": { + "additionalProperties": true, + "title": "Trigger", + "type": "object" + }, + "ttl_seconds": { + "exclusiveMinimum": 0.0, + "title": "Ttl Seconds", + "type": "integer" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "mode", + "trigger", + "ttl_seconds", + "default_on_timeout", + "created_at", + "updated_at" + ], + "title": "OversightPolicyRead", + "type": "object" + }, + "OversightPolicyUpdate": { + "properties": { + "default_on_timeout": { + "anyOf": [ + { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Default On Timeout" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "mode": { + "anyOf": [ + { + "const": "flag", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mode" + }, + "name": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "trigger": { + "anyOf": [ + { + "additionalProperties": true, + "minProperties": 1, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Trigger" + }, + "ttl_seconds": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ttl Seconds" + } + }, + "title": "OversightPolicyUpdate", + "type": "object" + }, + "OversightRequestRead": { + "properties": { + "approver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Approver" + }, + "audit_log_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audit Log Id" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Decided At" + }, + "decision_note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Decision Note" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "policy_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Policy Id" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "request_ref": { + "title": "Request Ref", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "approved", + "rejected", + "expired" + ], + "title": "Status", + "type": "string" + }, + "trigger_detail": { + "additionalProperties": true, + "title": "Trigger Detail", + "type": "object" + } + }, + "required": [ + "id", + "request_ref", + "trigger_detail", + "status", + "created_at" + ], + "title": "OversightRequestRead", + "type": "object" + }, + "OverviewDashboardView": { + "properties": { + "current": { + "$ref": "#/components/schemas/OverviewSummaryView" + }, + "generated_at": { + "format": "date-time", + "title": "Generated At", + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/OverviewPeriodView" + }, + "previous": { + "$ref": "#/components/schemas/OverviewSummaryView" + }, + "recent_exceptions": { + "items": { + "$ref": "#/components/schemas/OverviewExceptionView" + }, + "title": "Recent Exceptions", + "type": "array" + }, + "setup": { + "$ref": "#/components/schemas/OverviewSetupView" + }, + "trend": { + "items": { + "$ref": "#/components/schemas/OverviewTrendPointView" + }, + "title": "Trend", + "type": "array" + } + }, + "required": [ + "generated_at", + "period", + "current", + "previous", + "trend", + "recent_exceptions", + "setup" + ], + "title": "OverviewDashboardView", + "type": "object" + }, + "OverviewExceptionView": { + "properties": { + "category": { + "enum": [ + "technical_failure", + "policy_rejection", + "client_cancelled" + ], + "title": "Category", + "type": "string" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Code" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "status": { + "enum": [ + "provider_error", + "client_disconnected", + "timeout", + "cancelled", + "internal_error", + "rejected", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "request_id", + "occurred_at", + "status", + "category", + "provider", + "model", + "error_code" + ], + "title": "OverviewExceptionView", + "type": "object" + }, + "OverviewPeriodView": { + "properties": { + "bucket": { + "enum": [ + "hour", + "day" + ], + "title": "Bucket", + "type": "string" + }, + "end": { + "format": "date-time", + "title": "End", + "type": "string" + }, + "previous_end": { + "format": "date-time", + "title": "Previous End", + "type": "string" + }, + "previous_start": { + "format": "date-time", + "title": "Previous Start", + "type": "string" + }, + "start": { + "format": "date-time", + "title": "Start", + "type": "string" + } + }, + "required": [ + "start", + "end", + "previous_start", + "previous_end", + "bucket" + ], + "title": "OverviewPeriodView", + "type": "object" + }, + "OverviewResponse": { + "properties": { + "audit_log": { + "$ref": "#/components/schemas/AuditOverview" + }, + "connectors": { + "$ref": "#/components/schemas/ConnectorOverview" + }, + "detective": { + "$ref": "#/components/schemas/DetectiveOverview" + }, + "preventive": { + "$ref": "#/components/schemas/PreventiveOverview" + } + }, + "required": [ + "detective", + "preventive", + "audit_log", + "connectors" + ], + "title": "OverviewResponse", + "type": "object" + }, + "OverviewSetupView": { + "properties": { + "active_gateway_key": { + "title": "Active Gateway Key", + "type": "boolean" + }, + "complete": { + "title": "Complete", + "type": "boolean" + }, + "first_successful_request": { + "title": "First Successful Request", + "type": "boolean" + }, + "protection_enabled": { + "title": "Protection Enabled", + "type": "boolean" + }, + "verified_provider": { + "title": "Verified Provider", + "type": "boolean" + } + }, + "required": [ + "verified_provider", + "active_gateway_key", + "protection_enabled", + "first_successful_request", + "complete" + ], + "title": "OverviewSetupView", + "type": "object" + }, + "OverviewStatusCountsView": { + "properties": { + "cancelled": { + "minimum": 0.0, + "title": "Cancelled", + "type": "integer" + }, + "client_disconnected": { + "minimum": 0.0, + "title": "Client Disconnected", + "type": "integer" + }, + "completed": { + "minimum": 0.0, + "title": "Completed", + "type": "integer" + }, + "failed": { + "minimum": 0.0, + "title": "Failed", + "type": "integer" + }, + "internal_error": { + "minimum": 0.0, + "title": "Internal Error", + "type": "integer" + }, + "provider_error": { + "minimum": 0.0, + "title": "Provider Error", + "type": "integer" + }, + "rejected": { + "minimum": 0.0, + "title": "Rejected", + "type": "integer" + }, + "timeout": { + "minimum": 0.0, + "title": "Timeout", + "type": "integer" + } + }, + "required": [ + "completed", + "provider_error", + "client_disconnected", + "timeout", + "cancelled", + "internal_error", + "rejected", + "failed" + ], + "title": "OverviewStatusCountsView", + "type": "object" + }, + "OverviewSummaryView": { + "properties": { + "cost_complete": { + "title": "Cost Complete", + "type": "boolean" + }, + "p95_completed_latency_ms": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "P95 Completed Latency Ms" + }, + "policy_rejections": { + "minimum": 0.0, + "title": "Policy Rejections", + "type": "integer" + }, + "requests": { + "minimum": 0.0, + "title": "Requests", + "type": "integer" + }, + "settled_spend_usd": { + "anyOf": [ + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Settled Spend Usd" + }, + "status_counts": { + "$ref": "#/components/schemas/OverviewStatusCountsView" + }, + "technical_failures": { + "minimum": 0.0, + "title": "Technical Failures", + "type": "integer" + }, + "technical_success_rate": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Technical Success Rate" + }, + "unpriced_requests": { + "minimum": 0.0, + "title": "Unpriced Requests", + "type": "integer" + } + }, + "required": [ + "requests", + "technical_failures", + "policy_rejections", + "technical_success_rate", + "p95_completed_latency_ms", + "settled_spend_usd", + "cost_complete", + "unpriced_requests", + "status_counts" + ], + "title": "OverviewSummaryView", + "type": "object" + }, + "OverviewTrendPointView": { + "properties": { + "cost_complete": { + "title": "Cost Complete", + "type": "boolean" + }, + "requests": { + "minimum": 0.0, + "title": "Requests", + "type": "integer" + }, + "settled_spend_usd": { + "anyOf": [ + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Settled Spend Usd" + }, + "start": { + "format": "date-time", + "title": "Start", + "type": "string" + }, + "unpriced_requests": { + "minimum": 0.0, + "title": "Unpriced Requests", + "type": "integer" + } + }, + "required": [ + "start", + "requests", + "settled_spend_usd", + "cost_complete", + "unpriced_requests" + ], + "title": "OverviewTrendPointView", + "type": "object" + }, + "Part": { + "additionalProperties": false, + "description": "A datatype containing media content.\n\nExactly one field within a Part should be set, representing the specific type\nof content being conveyed. Using multiple fields within the same `Part`\ninstance is considered invalid.", + "properties": { + "audioTranscription": { + "anyOf": [ + { + "$ref": "#/components/schemas/Transcription" + }, + { + "type": "null" + } + ], + "description": "Output only. The transcription of the audio part." + }, + "codeExecutionResult": { + "anyOf": [ + { + "$ref": "#/components/schemas/CodeExecutionResult" + }, + { + "type": "null" + } + ], + "description": "Optional. The result of executing the ExecutableCode." + }, + "executableCode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecutableCode" + }, + { + "type": "null" + } + ], + "description": "Optional. Code generated by the model that is intended to be executed." + }, + "fileData": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileData" + }, + { + "type": "null" + } + ], + "description": "Optional. The URI-based data of the part. This can be used to include files from Google Cloud Storage." + }, + "functionCall": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionCall" + }, + { + "type": "null" + } + ], + "description": "Optional. A predicted function call returned from the model. This contains the name of the function to call and the arguments to pass to the function." + }, + "functionResponse": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionResponse" + }, + { + "type": "null" + } + ], + "description": "Optional. The result of a function call. This is used to provide the model with the result of a function call that it predicted." + }, + "inlineData": { + "anyOf": [ + { + "$ref": "#/components/schemas/Blob" + }, + { + "type": "null" + } + ], + "description": "Optional. The inline data content of the part. This can be used to include images, audio, or video in a request." + }, + "mediaResolution": { + "anyOf": [ + { + "$ref": "#/components/schemas/PartMediaResolution" + }, + { + "type": "null" + } + ], + "description": "Media resolution for the input media.\n " + }, + "partMetadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Custom metadata associated with the Part. Agents using genai.Part as content representation may need to keep track of the additional information. For example it can be name of a file/source from which the Part originates or a way to multiplex multiple Part streams. This field is not supported in Vertex AI.", + "title": "Partmetadata" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The text content of the part. When sent from the VSCode Gemini Code Assist extension, references to @mentioned items will be converted to markdown boldface text. For example `@my-repo` will be converted to and sent as `**my-repo**` by the IDE agent.", + "title": "Text" + }, + "thought": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Indicates whether the `part` represents the model's thought process or reasoning.", + "title": "Thought" + }, + "thoughtSignature": { + "anyOf": [ + { + "contentEncoding": "base64", + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. An opaque signature for the thought so it can be reused in subsequent requests.", + "title": "Thoughtsignature" + }, + "toolCall": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolCall" + }, + { + "type": "null" + } + ], + "description": "Server-side tool call. This field is populated when the model predicts a tool invocation that should be executed on the server. The client is expected to echo this message back to the API." + }, + "toolResponse": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolResponse" + }, + { + "type": "null" + } + ], + "description": "The output from a server-side ToolCall execution. This field is populated by the client with the results of executing the corresponding ToolCall." + }, + "videoMetadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/VideoMetadata" + }, + { + "type": "null" + } + ], + "description": "Optional. Video metadata. The metadata should only be specified while the video data is presented in inline_data or file_data." + } + }, + "title": "Part", + "type": "object" + }, + "PartMediaResolution": { + "additionalProperties": false, + "description": "Media resolution for the input media.", + "properties": { + "level": { + "anyOf": [ + { + "$ref": "#/components/schemas/PartMediaResolutionLevel" + }, + { + "type": "null" + } + ], + "description": "The tokenization quality used for given media.\n " + }, + "numTokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Specifies the required sequence length for media tokenization.\n ", + "title": "Numtokens" + } + }, + "title": "PartMediaResolution", + "type": "object" + }, + "PartMediaResolutionLevel": { + "description": "The tokenization quality used for given media.", + "enum": [ + "MEDIA_RESOLUTION_UNSPECIFIED", + "MEDIA_RESOLUTION_LOW", + "MEDIA_RESOLUTION_MEDIUM", + "MEDIA_RESOLUTION_HIGH", + "MEDIA_RESOLUTION_ULTRA_HIGH" + ], + "title": "PartMediaResolutionLevel", + "type": "string" + }, + "PartialArg": { + "additionalProperties": false, + "description": "Partial argument value of the function call.\n\nThis data type is not supported in Gemini API.", + "properties": { + "boolValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Represents a boolean value.", + "title": "Boolvalue" + }, + "jsonPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. A JSON Path (RFC 9535) to the argument being streamed. https://datatracker.ietf.org/doc/html/rfc9535. e.g. \"$.foo.bar[0].data\".", + "title": "Jsonpath" + }, + "nullValue": { + "anyOf": [ + { + "const": "NULL_VALUE", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Represents a null value.", + "title": "Nullvalue" + }, + "numberValue": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Represents a double value.", + "title": "Numbervalue" + }, + "stringValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Represents a string value.", + "title": "Stringvalue" + }, + "willContinue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Whether this is not the last part of the same json_path. If true, another PartialArg message for the current json_path is expected to follow.", + "title": "Willcontinue" + } + }, + "title": "PartialArg", + "type": "object" + }, + "PhishBlockThreshold": { + "description": "Sites with confidence level chosen & above this value will be blocked from the search results.\n\nThis enum is not supported in Gemini API.", + "enum": [ + "PHISH_BLOCK_THRESHOLD_UNSPECIFIED", + "BLOCK_LOW_AND_ABOVE", + "BLOCK_MEDIUM_AND_ABOVE", + "BLOCK_HIGH_AND_ABOVE", + "BLOCK_HIGHER_AND_ABOVE", + "BLOCK_VERY_HIGH_AND_ABOVE", + "BLOCK_ONLY_EXTREMELY_HIGH" + ], + "title": "PhishBlockThreshold", + "type": "string" + }, + "PrebuiltVoiceConfig": { + "additionalProperties": false, + "description": "Configuration for a prebuilt voice.", + "properties": { + "voiceName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The name of the prebuilt voice to use.", + "title": "Voicename" + } + }, + "title": "PrebuiltVoiceConfig", + "type": "object" + }, + "PreventiveOverview": { + "properties": { + "cache_hit_rate": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Cache Hit Rate", + "type": "number" + }, + "pii_detected_requests": { + "minimum": 0.0, + "title": "Pii Detected Requests", + "type": "integer" + }, + "redaction_rate": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Redaction Rate", + "type": "number" + }, + "top_entity_types": { + "additionalProperties": { + "type": "integer" + }, + "title": "Top Entity Types", + "type": "object" + }, + "total_requests": { + "minimum": 0.0, + "title": "Total Requests", + "type": "integer" + } + }, + "required": [ + "total_requests", + "pii_detected_requests", + "redaction_rate", + "cache_hit_rate", + "top_entity_types" + ], + "title": "PreventiveOverview", + "type": "object" + }, + "PrivacyPatch": { + "properties": { + "block_credit_card": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Credit Card" + }, + "block_email": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Email" + }, + "block_phone": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Phone" + }, + "block_pii_tr": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Pii Tr" + }, + "block_secrets": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Secrets" + } + }, + "title": "PrivacyPatch", + "type": "object" + }, + "PrivacySettings": { + "properties": { + "block_credit_card": { + "title": "Block Credit Card", + "type": "boolean" + }, + "block_email": { + "title": "Block Email", + "type": "boolean" + }, + "block_phone": { + "title": "Block Phone", + "type": "boolean" + }, + "block_pii_tr": { + "title": "Block Pii Tr", + "type": "boolean" + }, + "block_secrets": { + "title": "Block Secrets", + "type": "boolean" + } + }, + "required": [ + "block_email", + "block_phone", + "block_credit_card", + "block_secrets", + "block_pii_tr" + ], + "title": "PrivacySettings", + "type": "object" + }, + "ProductChoice": { + "properties": { + "interval": { + "enum": [ + "monthly", + "yearly" + ], + "title": "Interval", + "type": "string" + }, + "plan": { + "enum": [ + "managed", + "agency" + ], + "title": "Plan", + "type": "string" + } + }, + "required": [ + "plan", + "interval" + ], + "title": "ProductChoice", + "type": "object" + }, + "ProviderSecretInput": { + "properties": { + "key": { + "maxLength": 10000, + "minLength": 10, + "title": "Key", + "type": "string" + }, + "monthly_limit_usd": { + "anyOf": [ + { + "minimum": 0.0, + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Monthly Limit Usd" + }, + "name": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "provider": { + "enum": [ + "openai", + "anthropic", + "google" + ], + "title": "Provider", + "type": "string" + } + }, + "required": [ + "provider", + "key" + ], + "title": "ProviderSecretInput", + "type": "object" + }, + "ProviderSecretPatch": { + "properties": { + "key": { + "anyOf": [ + { + "maxLength": 10000, + "minLength": 10, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key" + }, + "monthly_limit_usd": { + "anyOf": [ + { + "minimum": 0.0, + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Monthly Limit Usd" + }, + "name": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + "title": "ProviderSecretPatch", + "type": "object" + }, + "ProviderSecretView": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "masked_key": { + "title": "Masked Key", + "type": "string" + }, + "monthly_limit_usd": { + "anyOf": [ + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Monthly Limit Usd" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "verified_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Verified At" + } + }, + "required": [ + "id", + "provider", + "name", + "masked_key", + "created_at", + "monthly_limit_usd", + "verified_at" + ], + "title": "ProviderSecretView", + "type": "object" + }, + "RagRetrievalConfig": { + "additionalProperties": false, + "description": "Specifies the context retrieval config.\n\nThis data type is not supported in Gemini API.", + "properties": { + "filter": { + "anyOf": [ + { + "$ref": "#/components/schemas/RagRetrievalConfigFilter" + }, + { + "type": "null" + } + ], + "description": "Optional. Config for filters." + }, + "hybridSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/RagRetrievalConfigHybridSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. Config for Hybrid Search." + }, + "ranking": { + "anyOf": [ + { + "$ref": "#/components/schemas/RagRetrievalConfigRanking" + }, + { + "type": "null" + } + ], + "description": "Optional. Config for ranking and reranking." + }, + "topK": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. The number of contexts to retrieve.", + "title": "Topk" + } + }, + "title": "RagRetrievalConfig", + "type": "object" + }, + "RagRetrievalConfigFilter": { + "additionalProperties": false, + "description": "Config for filters. This data type is not supported in Gemini API.", + "properties": { + "metadataFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. String for metadata filtering.", + "title": "Metadatafilter" + }, + "vectorDistanceThreshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Only returns contexts with vector distance smaller than the threshold.", + "title": "Vectordistancethreshold" + }, + "vectorSimilarityThreshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Only returns contexts with vector similarity larger than the threshold.", + "title": "Vectorsimilaritythreshold" + } + }, + "title": "RagRetrievalConfigFilter", + "type": "object" + }, + "RagRetrievalConfigHybridSearch": { + "additionalProperties": false, + "description": "Config for Hybrid Search. This data type is not supported in Gemini API.", + "properties": { + "alpha": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Alpha value controls the weight between dense and sparse vector search results. The range is [0, 1], while 0 means sparse vector search only and 1 means dense vector search only. The default value is 0.5 which balances sparse and dense vector search equally.", + "title": "Alpha" + } + }, + "title": "RagRetrievalConfigHybridSearch", + "type": "object" + }, + "RagRetrievalConfigRanking": { + "additionalProperties": false, + "description": "Config for ranking and reranking.\n\nThis data type is not supported in Gemini API.", + "properties": { + "llmRanker": { + "anyOf": [ + { + "$ref": "#/components/schemas/RagRetrievalConfigRankingLlmRanker" + }, + { + "type": "null" + } + ], + "description": "Optional. Config for LlmRanker." + }, + "rankService": { + "anyOf": [ + { + "$ref": "#/components/schemas/RagRetrievalConfigRankingRankService" + }, + { + "type": "null" + } + ], + "description": "Optional. Config for Rank Service." + } + }, + "title": "RagRetrievalConfigRanking", + "type": "object" + }, + "RagRetrievalConfigRankingLlmRanker": { + "additionalProperties": false, + "description": "Config for LlmRanker. This data type is not supported in Gemini API.", + "properties": { + "modelName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The model name used for ranking. See [Supported models](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#supported-models).", + "title": "Modelname" + } + }, + "title": "RagRetrievalConfigRankingLlmRanker", + "type": "object" + }, + "RagRetrievalConfigRankingRankService": { + "additionalProperties": false, + "description": "Config for Rank Service. This data type is not supported in Gemini API.", + "properties": { + "modelName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The model name of the rank service. Format: `semantic-ranker-512@latest`", + "title": "Modelname" + } + }, + "title": "RagRetrievalConfigRankingRankService", + "type": "object" + }, + "ReplicatedVoiceConfig": { + "additionalProperties": false, + "description": "The configuration for the replicated voice to use.", + "properties": { + "consentAudio": { + "anyOf": [ + { + "contentEncoding": "base64", + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Recorded consent verifying ownership of the voice. This\n represents 16-bit signed little-endian wav data, with a 24kHz sampling\n rate.", + "title": "Consentaudio" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The mimetype of the voice sample. The only currently supported\n value is `audio/wav`. This represents 16-bit signed little-endian wav\n data, with a 24kHz sampling rate.\n ", + "title": "Mimetype" + }, + "voiceConsentSignature": { + "anyOf": [ + { + "$ref": "#/components/schemas/VoiceConsentSignature" + }, + { + "type": "null" + } + ], + "description": "Signature of a previously verified consent audio. This should be\n populated with a signature generated by the server for a previous\n request containing the consent_audio field. When provided, the\n signature is verified instead of the consent_audio field to reduce\n latency. Requests will fail if the signature is invalid or expired." + }, + "voiceSampleAudio": { + "anyOf": [ + { + "contentEncoding": "base64", + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The sample of the custom voice.\n ", + "title": "Voicesampleaudio" + } + }, + "title": "ReplicatedVoiceConfig", + "type": "object" + }, + "ReportRequest": { + "properties": { + "connector_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connector Id" + }, + "end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + }, + "format": { + "default": "pdf", + "enum": [ + "pdf", + "csv" + ], + "title": "Format", + "type": "string" + }, + "start": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + "title": "ReportRequest", + "type": "object" + }, + "RequestActivityPage": { + "properties": { + "generated_at": { + "format": "date-time", + "title": "Generated At", + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/RequestActivityView" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "maximum": 200.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "offset": { + "minimum": 0.0, + "title": "Offset", + "type": "integer" + }, + "summary": { + "$ref": "#/components/schemas/RequestActivitySummaryView" + }, + "total": { + "minimum": 0.0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "generated_at", + "summary", + "items", + "total", + "limit", + "offset" + ], + "title": "RequestActivityPage", + "type": "object" + }, + "RequestActivityStatusCountsView": { + "properties": { + "cancelled": { + "minimum": 0.0, + "title": "Cancelled", + "type": "integer" + }, + "client_disconnected": { + "minimum": 0.0, + "title": "Client Disconnected", + "type": "integer" + }, + "completed": { + "minimum": 0.0, + "title": "Completed", + "type": "integer" + }, + "failed": { + "minimum": 0.0, + "title": "Failed", + "type": "integer" + }, + "internal_error": { + "minimum": 0.0, + "title": "Internal Error", + "type": "integer" + }, + "provider_error": { + "minimum": 0.0, + "title": "Provider Error", + "type": "integer" + }, + "rejected": { + "minimum": 0.0, + "title": "Rejected", + "type": "integer" + }, + "timeout": { + "minimum": 0.0, + "title": "Timeout", + "type": "integer" + }, + "unknown": { + "minimum": 0.0, + "title": "Unknown", + "type": "integer" + } + }, + "required": [ + "completed", + "provider_error", + "client_disconnected", + "timeout", + "cancelled", + "internal_error", + "rejected", + "failed", + "unknown" + ], + "title": "RequestActivityStatusCountsView", + "type": "object" + }, + "RequestActivitySummaryView": { + "properties": { + "completion_tokens": { + "minimum": 0.0, + "title": "Completion Tokens", + "type": "integer" + }, + "cost_complete": { + "title": "Cost Complete", + "type": "boolean" + }, + "p95_completed_latency_ms": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "P95 Completed Latency Ms" + }, + "pii_detected_requests": { + "minimum": 0.0, + "title": "Pii Detected Requests", + "type": "integer" + }, + "policy_rejections": { + "minimum": 0.0, + "title": "Policy Rejections", + "type": "integer" + }, + "prompt_tokens": { + "minimum": 0.0, + "title": "Prompt Tokens", + "type": "integer" + }, + "requests": { + "minimum": 0.0, + "title": "Requests", + "type": "integer" + }, + "settled_spend_usd": { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "title": "Settled Spend Usd", + "type": "string" + }, + "status_counts": { + "$ref": "#/components/schemas/RequestActivityStatusCountsView" + }, + "technical_failures": { + "minimum": 0.0, + "title": "Technical Failures", + "type": "integer" + }, + "technical_success_rate": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Technical Success Rate" + }, + "unpriced_requests": { + "minimum": 0.0, + "title": "Unpriced Requests", + "type": "integer" + } + }, + "required": [ + "requests", + "technical_success_rate", + "p95_completed_latency_ms", + "settled_spend_usd", + "cost_complete", + "unpriced_requests", + "prompt_tokens", + "completion_tokens", + "pii_detected_requests", + "technical_failures", + "policy_rejections", + "status_counts" + ], + "title": "RequestActivitySummaryView", + "type": "object" + }, + "RequestActivityView": { + "properties": { + "completion_tokens": { + "minimum": 0.0, + "title": "Completion Tokens", + "type": "integer" + }, + "cost_center": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Center" + }, + "cost_complete": { + "title": "Cost Complete", + "type": "boolean" + }, + "cost_usd": { + "anyOf": [ + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cost Usd" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "deployment_kind": { + "anyOf": [ + { + "enum": [ + "internal", + "external", + "unknown" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deployment Kind" + }, + "endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Endpoint" + }, + "latency_ms": { + "minimum": 0.0, + "title": "Latency Ms", + "type": "integer" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "pii_detected": { + "title": "Pii Detected", + "type": "boolean" + }, + "prompt_tokens": { + "minimum": 0.0, + "title": "Prompt Tokens", + "type": "integer" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider" + }, + "provider_finish_reasons": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Provider Finish Reasons" + }, + "repeat_chain_length": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Repeat Chain Length" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "status": { + "enum": [ + "completed", + "provider_error", + "client_disconnected", + "timeout", + "cancelled", + "internal_error", + "rejected", + "failed", + "unknown" + ], + "title": "Status", + "type": "string" + }, + "system_prompt_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "System Prompt Hash" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "team": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team" + }, + "ttft_ms": { + "anyOf": [ + { + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ttft Ms" + }, + "usage_estimated": { + "title": "Usage Estimated", + "type": "boolean" + } + }, + "required": [ + "request_id", + "created_at", + "endpoint", + "model", + "status", + "prompt_tokens", + "completion_tokens", + "usage_estimated", + "cost_usd", + "cost_complete", + "latency_ms", + "pii_detected", + "cost_center", + "provider", + "team" + ], + "title": "RequestActivityView", + "type": "object" + }, + "ResponseFormat": { + "additionalProperties": false, + "description": "Configuration for the model to configure output formatting and delivery.\n\nThis data type is not supported in Gemini API.", + "properties": { + "audio": { + "anyOf": [ + { + "$ref": "#/components/schemas/AudioResponseFormat" + }, + { + "type": "null" + } + ], + "description": "Audio output format." + }, + "image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageResponseFormat" + }, + { + "type": "null" + } + ], + "description": "Image output format." + }, + "text": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextResponseFormat" + }, + { + "type": "null" + } + ], + "description": "Text output format." + }, + "video": { + "anyOf": [ + { + "$ref": "#/components/schemas/VideoResponseFormat" + }, + { + "type": "null" + } + ], + "description": "Video output format." + } + }, + "title": "ResponseFormat", + "type": "object" + }, + "ResponsesRequest": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "ResponsesRequest", + "type": "object" + }, + "Retrieval": { + "additionalProperties": false, + "description": "Defines a retrieval tool that model can call to access external knowledge.\n\nThis data type is not supported in Gemini API.", + "properties": { + "disableAttribution": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Deprecated. This option is no longer supported.", + "title": "Disableattribution" + }, + "externalApi": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalApi" + }, + { + "type": "null" + } + ], + "description": "Use data source powered by external API for grounding." + }, + "vertexAiSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/VertexAISearch" + }, + { + "type": "null" + } + ], + "description": "Set to use data source powered by Vertex AI Search." + }, + "vertexRagStore": { + "anyOf": [ + { + "$ref": "#/components/schemas/VertexRagStore" + }, + { + "type": "null" + } + ], + "description": "Set to use data source powered by Vertex RAG store. User data is uploaded via the VertexRagDataService." + } + }, + "title": "Retrieval", + "type": "object" + }, + "RetrievalConfig": { + "additionalProperties": false, + "description": "Retrieval config.", + "properties": { + "languageCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The language code of the user.", + "title": "Languagecode" + }, + "latLng": { + "anyOf": [ + { + "$ref": "#/components/schemas/LatLng" + }, + { + "type": "null" + } + ], + "description": "The location of the user." + } + }, + "title": "RetrievalConfig", + "type": "object" + }, + "RunResult": { + "properties": { + "activities_ingested": { + "default": 0, + "title": "Activities Ingested", + "type": "integer" + }, + "connector_id": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + }, + "content_scanned": { + "default": 0, + "title": "Content Scanned", + "type": "integer" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "findings_created": { + "default": 0, + "title": "Findings Created", + "type": "integer" + }, + "status": { + "enum": [ + "completed", + "skipped_locked", + "skipped_paused", + "error" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "connector_id", + "status" + ], + "title": "RunResult", + "type": "object" + }, + "SafetyPolicy": { + "description": "SafetyPolicy", + "enum": [ + "SAFETY_POLICY_UNSPECIFIED", + "FINANCIAL_TRANSACTIONS", + "SENSITIVE_DATA_MODIFICATION", + "COMMUNICATION_TOOL", + "ACCOUNT_CREATION", + "DATA_MODIFICATION", + "USER_CONSENT_MANAGEMENT", + "LEGAL_TERMS_AND_AGREEMENTS" + ], + "title": "SafetyPolicy", + "type": "string" + }, + "SafetySetting": { + "additionalProperties": false, + "description": "A safety setting that affects the safety-blocking behavior.\n\nA SafetySetting consists of a harm category and a threshold for that category.", + "properties": { + "category": { + "anyOf": [ + { + "$ref": "#/components/schemas/HarmCategory" + }, + { + "type": "null" + } + ], + "description": "Required. The harm category to be blocked." + }, + "method": { + "anyOf": [ + { + "$ref": "#/components/schemas/HarmBlockMethod" + }, + { + "type": "null" + } + ], + "description": "Optional. The method for blocking content. If not specified, the default behavior is to use the probability score. This field is not supported in Gemini API." + }, + "threshold": { + "anyOf": [ + { + "$ref": "#/components/schemas/HarmBlockThreshold" + }, + { + "type": "null" + } + ], + "description": "Required. The threshold for blocking content. If the harm probability exceeds this threshold, the content will be blocked." + } + }, + "title": "SafetySetting", + "type": "object" + }, + "ScanInput": { + "additionalProperties": false, + "properties": { + "source": { + "default": "unknown", + "enum": [ + "chatgpt", + "gemini", + "unknown" + ], + "title": "Source", + "type": "string" + }, + "text": { + "maxLength": 50000, + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "ScanInput", + "type": "object" + }, + "ScanResponse": { + "properties": { + "entities_found": { + "items": { + "$ref": "#/components/schemas/EntityFound" + }, + "title": "Entities Found", + "type": "array" + }, + "entity_types": { + "items": { + "type": "string" + }, + "title": "Entity Types", + "type": "array" + }, + "policy": { + "title": "Policy", + "type": "string" + }, + "scan_count": { + "title": "Scan Count", + "type": "integer" + }, + "scan_limit": { + "title": "Scan Limit", + "type": "integer" + }, + "scans_remaining": { + "title": "Scans Remaining", + "type": "integer" + }, + "verdict": { + "title": "Verdict", + "type": "string" + } + }, + "required": [ + "verdict", + "entities_found", + "entity_types", + "scan_count", + "scan_limit", + "scans_remaining", + "policy" + ], + "title": "ScanResponse", + "type": "object" + }, + "ScanUsageResponse": { + "properties": { + "resets_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resets At" + }, + "scan_count": { + "title": "Scan Count", + "type": "integer" + }, + "scan_limit": { + "title": "Scan Limit", + "type": "integer" + }, + "scans_remaining": { + "title": "Scans Remaining", + "type": "integer" + } + }, + "required": [ + "scan_count", + "scan_limit", + "scans_remaining", + "resets_at" + ], + "title": "ScanUsageResponse", + "type": "object" + }, + "Schema": { + "additionalProperties": false, + "description": "Schema is used to define the format of input/output data.\n\nRepresents a select subset of an [OpenAPI 3.0 schema\nobject](https://spec.openapis.org/oas/v3.0.3#schema-object). More fields may\nbe added in the future as needed.", + "properties": { + "additionalProperties": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Optional. Can either be a boolean or an object; controls the presence of additional properties.", + "title": "Additionalproperties" + }, + "anyOf": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Schema" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. The instance must be valid against any (one or more) of the subschemas listed in `any_of`.", + "title": "Anyof" + }, + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Optional. Default value to use if the field is not specified.", + "title": "Default" + }, + "defs": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/Schema" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional. A map of definitions for use by `ref` Only allowed at the root of the schema.", + "title": "Defs" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Describes the data. The model uses this field to understand the purpose of the schema and how to use it. It is a best practice to provide a clear and descriptive explanation for the schema and its properties here, rather than in the prompt.", + "title": "Description" + }, + "enum": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. Possible values of the field. This field can be used to restrict a value to a fixed set of values. To mark a field as an enum, set `format` to `enum` and provide the list of possible values in `enum`. For example: 1. To define directions: `{type:STRING, format:enum, enum:[\"EAST\", \"NORTH\", \"SOUTH\", \"WEST\"]}` 2. To define apartment numbers: `{type:INTEGER, format:enum, enum:[\"101\", \"201\", \"301\"]}`", + "title": "Enum" + }, + "example": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Optional. Example of an instance of this schema.", + "title": "Example" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The format of the data. For `NUMBER` type, format can be `float` or `double`. For `INTEGER` type, format can be `int32` or `int64`. For `STRING` type, format can be `email`, `byte`, `date`, `date-time`, `password`, and other formats to further refine the data type.", + "title": "Format" + }, + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Schema" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `ARRAY`, `items` specifies the schema of elements in the array." + }, + "maxItems": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `ARRAY`, `max_items` specifies the maximum number of items in an array.", + "title": "Maxitems" + }, + "maxLength": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `STRING`, `max_length` specifies the maximum length of the string.", + "title": "Maxlength" + }, + "maxProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `OBJECT`, `max_properties` specifies the maximum number of properties that can be provided.", + "title": "Maxproperties" + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `INTEGER` or `NUMBER`, `maximum` specifies the maximum allowed value.", + "title": "Maximum" + }, + "minItems": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `ARRAY`, `min_items` specifies the minimum number of items in an array.", + "title": "Minitems" + }, + "minLength": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `STRING`, `min_length` specifies the minimum length of the string.", + "title": "Minlength" + }, + "minProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `OBJECT`, `min_properties` specifies the minimum number of properties that can be provided.", + "title": "Minproperties" + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `INTEGER` or `NUMBER`, `minimum` specifies the minimum allowed value.", + "title": "Minimum" + }, + "nullable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Indicates if the value of this field can be null.", + "title": "Nullable" + }, + "pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `STRING`, `pattern` specifies a regular expression that the string must match.", + "title": "Pattern" + }, + "properties": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/Schema" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `OBJECT`, `properties` is a map of property names to schema definitions for each property of the object.", + "title": "Properties" + }, + "propertyOrdering": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. Order of properties displayed or used where order matters. This is not a standard field in OpenAPI specification, but can be used to control the order of properties.", + "title": "Propertyordering" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Allows indirect references between schema nodes. The value should be a valid reference to a child of the root `defs`. For example, the following schema defines a reference to a schema node named \"Pet\": type: object properties: pet: ref: #/defs/Pet defs: Pet: type: object properties: name: type: string The value of the \"pet\" property is a reference to the schema node named \"Pet\". See details in https://json-schema.org/understanding-json-schema/structuring", + "title": "Ref" + }, + "required": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. If type is `OBJECT`, `required` lists the names of properties that must be present.", + "title": "Required" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Title for the schema.", + "title": "Title" + }, + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/Type" + }, + { + "type": "null" + } + ], + "description": "Optional. Data type of the schema field." + } + }, + "title": "Schema", + "type": "object" + }, + "SearchTypes": { + "additionalProperties": false, + "description": "Different types of search that can be enabled on the GoogleSearch tool.", + "properties": { + "imageSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. Setting this field enables image search. Image bytes are returned." + }, + "webSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. Setting this field enables web search. Only text results are returned." + } + }, + "title": "SearchTypes", + "type": "object" + }, + "ServiceTier": { + "description": "Pricing and performance service tier.", + "enum": [ + "unspecified", + "flex", + "standard", + "priority" + ], + "title": "ServiceTier", + "type": "string" + }, + "SharedResultCreate": { + "additionalProperties": false, + "properties": { + "prompt": { + "maxLength": 4000, + "minLength": 1, + "title": "Prompt", + "type": "string" + }, + "response": { + "maxLength": 12000, + "minLength": 1, + "title": "Response", + "type": "string" + } + }, + "required": [ + "prompt", + "response" + ], + "title": "SharedResultCreate", + "type": "object" + }, + "SharedResultCreated": { + "properties": { + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "max_views": { + "title": "Max Views", + "type": "integer" + }, + "token": { + "title": "Token", + "type": "string" + } + }, + "required": [ + "token", + "expires_at", + "max_views" + ], + "title": "SharedResultCreated", + "type": "object" + }, + "SharedResultView": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "max_views": { + "title": "Max Views", + "type": "integer" + }, + "prompt": { + "title": "Prompt", + "type": "string" + }, + "response": { + "title": "Response", + "type": "string" + }, + "view_count": { + "title": "View Count", + "type": "integer" + } + }, + "required": [ + "prompt", + "response", + "created_at", + "expires_at", + "view_count", + "max_views" + ], + "title": "SharedResultView", + "type": "object" + }, + "SpeakerVoiceConfig": { + "additionalProperties": false, + "description": "Configuration for a single speaker in a multi-speaker setup.", + "properties": { + "speaker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The name of the speaker. This should be the same as the speaker name used in the prompt.", + "title": "Speaker" + }, + "voiceConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/VoiceConfig" + }, + { + "type": "null" + } + ], + "description": "Required. The configuration for the voice of this speaker." + } + }, + "title": "SpeakerVoiceConfig", + "type": "object" + }, + "SpeechConfig": { + "additionalProperties": false, + "description": "Config for speech generation and transcription.", + "properties": { + "languageCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The language code (ISO 639-1) for the speech synthesis.", + "title": "Languagecode" + }, + "multiSpeakerVoiceConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/MultiSpeakerVoiceConfig" + }, + { + "type": "null" + } + ], + "description": "The configuration for a multi-speaker text-to-speech request. This field is mutually exclusive with `voice_config`." + }, + "voiceConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/VoiceConfig" + }, + { + "type": "null" + } + ], + "description": "The configuration in case of single-voice output." + } + }, + "title": "SpeechConfig", + "type": "object" + }, + "StreamHealth": { + "properties": { + "event_type": { + "title": "Event Type", + "type": "string" + }, + "lag_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Lag Seconds" + }, + "last_end_time": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last End Time" + }, + "last_success_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Success At" + }, + "retention_budget_days": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Retention Budget Days" + } + }, + "required": [ + "event_type" + ], + "title": "StreamHealth", + "type": "object" + }, + "StreamableHttpTransport": { + "additionalProperties": false, + "description": "A transport that can stream HTTP requests and responses.\n\nNext ID: 6. This data type is not supported in Vertex AI.", + "properties": { + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional: Fields for authentication headers, timeouts, etc., if needed.", + "title": "Headers" + }, + "sseReadTimeout": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Timeout for SSE read operations.", + "title": "Ssereadtimeout" + }, + "terminateOnClose": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to close the client session when the transport closes.", + "title": "Terminateonclose" + }, + "timeout": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "HTTP timeout for regular operations.", + "title": "Timeout" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The full URL for the MCPServer endpoint. Example: \"https://api.example.com/mcp\".", + "title": "Url" + } + }, + "title": "StreamableHttpTransport", + "type": "object" + }, + "SubscriptionView": { + "properties": { + "entitlements": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Entitlements", + "type": "object" + }, + "plan": { + "enum": [ + "free", + "managed", + "agency", + "enterprise" + ], + "title": "Plan", + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "plan", + "status", + "source", + "entitlements" + ], + "title": "SubscriptionView", + "type": "object" + }, + "TeamInput": { + "properties": { + "daily_request_limit": { + "anyOf": [ + { + "maximum": 2000000000.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Daily Request Limit" + }, + "monthly_request_limit": { + "anyOf": [ + { + "maximum": 2000000000.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Monthly Request Limit" + }, + "monthly_token_limit": { + "anyOf": [ + { + "maximum": 2000000000.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Monthly Token Limit" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "TeamInput", + "type": "object" + }, + "TeamInviteInput": { + "properties": { + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "role": { + "default": "member", + "enum": [ + "admin", + "member", + "auditor" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "TeamInviteInput", + "type": "object" + }, + "TeamInviteView": { + "properties": { + "accepted_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accepted At" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "revoked_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + }, + "role": { + "enum": [ + "owner", + "admin", + "member", + "auditor" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "id", + "email", + "role", + "expires_at", + "accepted_at", + "revoked_at", + "created_at" + ], + "title": "TeamInviteView", + "type": "object" + }, + "TeamMemberView": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "full_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Full Name" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "is_active": { + "title": "Is Active", + "type": "boolean" + }, + "role": { + "enum": [ + "owner", + "admin", + "member", + "auditor" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "id", + "email", + "full_name", + "role", + "is_active", + "created_at" + ], + "title": "TeamMemberView", + "type": "object" + }, + "TeamRolePatch": { + "properties": { + "role": { + "enum": [ + "owner", + "admin", + "member", + "auditor" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "role" + ], + "title": "TeamRolePatch", + "type": "object" + }, + "TeamView": { + "properties": { + "daily_request_limit": { + "anyOf": [ + { + "maximum": 2000000000.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Daily Request Limit" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "monthly_request_limit": { + "anyOf": [ + { + "maximum": 2000000000.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Monthly Request Limit" + }, + "monthly_token_limit": { + "anyOf": [ + { + "maximum": 2000000000.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Monthly Token Limit" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name", + "id" + ], + "title": "TeamView", + "type": "object" + }, + "TextResponseFormat": { + "additionalProperties": false, + "description": "Configuration for text-specific output formatting.", + "properties": { + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The IANA standard MIME type of the response.", + "title": "Mimetype" + }, + "schema": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Optional. The JSON schema that the output should conform to. Only applicable when mime_type is APPLICATION_JSON.", + "title": "Schema" + } + }, + "title": "TextResponseFormat", + "type": "object" + }, + "ThinkingConfig": { + "additionalProperties": false, + "description": "The thinking features configuration.", + "properties": { + "includeThoughts": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Indicates whether to include thoughts in the response. If true, thoughts are returned only if the model supports thought and thoughts are available.\n ", + "title": "Includethoughts" + }, + "thinkingBudget": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Indicates the thinking budget in tokens. 0 is DISABLED. -1 is AUTOMATIC. The default values and allowed ranges are model dependent.\n ", + "title": "Thinkingbudget" + }, + "thinkingLevel": { + "anyOf": [ + { + "$ref": "#/components/schemas/ThinkingLevel" + }, + { + "type": "null" + } + ], + "description": "Optional. The number of thoughts tokens that the model should generate." + } + }, + "title": "ThinkingConfig", + "type": "object" + }, + "ThinkingLevel": { + "description": "The number of thoughts tokens that the model should generate.", + "enum": [ + "THINKING_LEVEL_UNSPECIFIED", + "MINIMAL", + "LOW", + "MEDIUM", + "HIGH" + ], + "title": "ThinkingLevel", + "type": "string" + }, + "TierView": { + "properties": { + "daily_request_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Daily Request Limit" + }, + "monthly_request_limit": { + "title": "Monthly Request Limit", + "type": "integer" + }, + "monthly_token_limit": { + "title": "Monthly Token Limit", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "rate_limit_rpm": { + "title": "Rate Limit Rpm", + "type": "integer" + }, + "rate_limit_tpm": { + "title": "Rate Limit Tpm", + "type": "integer" + }, + "tier": { + "title": "Tier", + "type": "string" + } + }, + "required": [ + "tier", + "name", + "rate_limit_rpm", + "rate_limit_tpm", + "daily_request_limit", + "monthly_request_limit", + "monthly_token_limit" + ], + "title": "TierView", + "type": "object" + }, + "Tool": { + "additionalProperties": false, + "description": "Tool details of a tool that the model may use to generate a response.", + "properties": { + "codeExecution": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolCodeExecution" + }, + { + "type": "null" + } + ], + "description": "Optional. CodeExecution tool type. Enables the model to execute code as part of generation." + }, + "computerUse": { + "anyOf": [ + { + "$ref": "#/components/schemas/ComputerUse" + }, + { + "type": "null" + } + ], + "description": "Optional. Tool to support the model interacting directly with the computer. If enabled, it automatically populates computer-use specific Function Declarations." + }, + "enterpriseWebSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/EnterpriseWebSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. Tool to support searching public web data, powered by Vertex AI Search and Sec4 compliance. This field is not supported in Gemini API." + }, + "exaAiSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolExaAiSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. Uses Exa.ai to search for information to answer user queries. The search results will be grounded on Exa.ai and presented to the model for response generation. This field is not supported in Gemini API." + }, + "fileSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. FileSearch tool type. Tool to retrieve knowledge from Semantic Retrieval corpora. This field is not supported in Vertex AI." + }, + "functionDeclarations": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/FunctionDeclaration" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. Function tool type. One or more function declarations to be passed to the model along with the current user query. Model may decide to call a subset of these functions by populating FunctionCall in the response. User should provide a FunctionResponse for each function call in the next turn. Based on the function responses, Model will generate the final response back to the user. Maximum 512 function declarations can be provided.", + "title": "Functiondeclarations" + }, + "googleMaps": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoogleMaps" + }, + { + "type": "null" + } + ], + "description": "Optional. Tool that allows grounding the model's response with\n geospatial context related to the user's query." + }, + "googleSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoogleSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. GoogleSearch tool type. Tool to support Google Search in Model. Powered by Google." + }, + "googleSearchRetrieval": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoogleSearchRetrieval" + }, + { + "type": "null" + } + ], + "description": "Optional. Specialized retrieval tool that is powered by Google Search." + }, + "mcpServers": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/McpServer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. MCP Servers to connect to. This field is not supported in Vertex AI.", + "title": "Mcpservers" + }, + "parallelAiSearch": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolParallelAiSearch" + }, + { + "type": "null" + } + ], + "description": "Optional. If specified, Vertex AI will use Parallel.ai to search for information to answer user queries. The search results will be grounded on Parallel.ai and presented to the model for response generation. This field is not supported in Gemini API." + }, + "retrieval": { + "anyOf": [ + { + "$ref": "#/components/schemas/Retrieval" + }, + { + "type": "null" + } + ], + "description": "Optional. Retrieval tool type. System will always execute the provided retrieval tool(s) to get external knowledge to answer the prompt. Retrieval results are presented to the model for generation. This field is not supported in Gemini API." + }, + "urlContext": { + "anyOf": [ + { + "$ref": "#/components/schemas/UrlContext" + }, + { + "type": "null" + } + ], + "description": "Optional. Tool to support URL context retrieval." + } + }, + "title": "Tool", + "type": "object" + }, + "ToolCall": { + "additionalProperties": false, + "description": "A predicted server-side `ToolCall` returned from the model.\n\nThis message contains information about a tool that the model wants to invoke.\nThe client is NOT expected to execute this `ToolCall`. Instead, the\nclient should pass this `ToolCall` back to the API in a subsequent turn\nwithin a `Content` message, along with the corresponding `ToolResponse`.", + "properties": { + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "The tool call arguments. Example: {\"arg1\": \"value1\", \"arg2\": \"value2\"}.", + "title": "Args" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Unique identifier of the tool call. The server returns the tool response with the matching `id`.", + "title": "Id" + }, + "toolType": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolType" + }, + { + "type": "null" + } + ], + "description": "The type of tool that was called." + } + }, + "title": "ToolCall", + "type": "object" + }, + "ToolCodeExecution": { + "additionalProperties": false, + "description": "Tool that executes code generated by the model, and automatically returns the result to the model.\n\nSee also ExecutableCode and CodeExecutionResult, which are input and output to\nthis tool. This data type is not supported in Gemini API.", + "properties": {}, + "title": "ToolCodeExecution", + "type": "object" + }, + "ToolConfig": { + "additionalProperties": false, + "description": "Tool config.\n\nThis config is shared for all tools provided in the request.", + "properties": { + "functionCallingConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionCallingConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Function calling config." + }, + "includeServerSideToolInvocations": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. If true, the API response will include the server-side tool calls and responses within the `Content` message. This allows clients to observe the server's tool interactions. This field is not supported in Vertex AI.", + "title": "Includeserversidetoolinvocations" + }, + "retrievalConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/RetrievalConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. Retrieval config." + } + }, + "title": "ToolConfig", + "type": "object" + }, + "ToolExaAiSearch": { + "additionalProperties": false, + "description": "ExaAiSearch tool type.\n\nA tool that uses the Exa.ai search engine for grounding. This data type is not\nsupported in Gemini API.", + "properties": { + "apiKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The API key for ExaAiSearch.", + "title": "Apikey" + }, + "customConfigs": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional. This field can be used to pass any parameter from the Exa.ai Search API.", + "title": "Customconfigs" + } + }, + "title": "ToolExaAiSearch", + "type": "object" + }, + "ToolParallelAiSearch": { + "additionalProperties": false, + "description": "ParallelAiSearch tool type.\n\nA tool that uses the Parallel.ai search engine for grounding. This data type\nis not supported in Gemini API.", + "properties": { + "apiKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The API key for ParallelAiSearch. If an API key is not provided, the system will attempt to verify access by checking for an active Parallel.ai subscription through the Google Cloud Marketplace. See https://docs.parallel.ai/search/search-quickstart for more details.", + "title": "Apikey" + }, + "customConfigs": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional. Custom configs for ParallelAiSearch. This field can be used to pass any parameter from the Parallel.ai Search API. See the Parallel.ai documentation for the full list of available parameters and their usage: https://docs.parallel.ai/api-reference/search-beta/search Currently only `source_policy`, `excerpts`, `max_results`, `mode`, `fetch_policy` can be set via this field. For example: { \"source_policy\": { \"include_domains\": [\"google.com\", \"wikipedia.org\"], \"exclude_domains\": [\"example.com\"] }, \"fetch_policy\": { \"max_age_seconds\": 3600 } }", + "title": "Customconfigs" + } + }, + "title": "ToolParallelAiSearch", + "type": "object" + }, + "ToolResponse": { + "additionalProperties": false, + "description": "The output from a server-side `ToolCall` execution.\n\nThis message contains the results of a tool invocation that was initiated by a\n`ToolCall` from the model. The client should pass this `ToolResponse` back to\nthe API in a subsequent turn within a `Content` message, along with the\ncorresponding `ToolCall`.", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The identifier of the tool call this response is for.", + "title": "Id" + }, + "response": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "The tool response.", + "title": "Response" + }, + "toolType": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolType" + }, + { + "type": "null" + } + ], + "description": "The type of tool that was called, matching the tool_type in the corresponding ToolCall." + } + }, + "title": "ToolResponse", + "type": "object" + }, + "ToolType": { + "description": "The type of tool in the function call.", + "enum": [ + "TOOL_TYPE_UNSPECIFIED", + "GOOGLE_SEARCH_WEB", + "GOOGLE_SEARCH_IMAGE", + "URL_CONTEXT", + "GOOGLE_MAPS", + "FILE_SEARCH" + ], + "title": "ToolType", + "type": "string" + }, + "TopActor": { + "properties": { + "actor_email": { + "title": "Actor Email", + "type": "string" + }, + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "actor_email", + "count" + ], + "title": "TopActor", + "type": "object" + }, + "Transcription": { + "additionalProperties": false, + "description": "Audio transcription in Server Content.", + "properties": { + "finished": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. The bool indicates the end of the transcription.", + "title": "Finished" + }, + "languageCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The BCP-47 language code of the transcription.", + "title": "Languagecode" + }, + "speakerLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A label identifying the speaker of this audio segment (e.g. \"spk_1\", \"spk_2\").\n ", + "title": "Speakerlabel" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Transcription text.", + "title": "Text" + }, + "words": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/WordInfo" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Detailed word-level transcriptions and timing details.\n ", + "title": "Words" + } + }, + "title": "Transcription", + "type": "object" + }, + "TranslationConfig": { + "additionalProperties": false, + "description": "Config for stream translation.", + "properties": { + "echoTargetLanguage": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. If true, the model will generate audio when the target language is spoken, essentially it will parrot the input. If false, we will not produce audio for the target language.", + "title": "Echotargetlanguage" + }, + "targetLanguageCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The target language for translation. Supported values are BCP-47 language codes (e.g. \"en\", \"es\", \"fr\").", + "title": "Targetlanguagecode" + } + }, + "title": "TranslationConfig", + "type": "object" + }, + "Type": { + "description": "Data type of the schema field.", + "enum": [ + "TYPE_UNSPECIFIED", + "STRING", + "NUMBER", + "INTEGER", + "BOOLEAN", + "ARRAY", + "OBJECT", + "NULL" + ], + "title": "Type", + "type": "string" + }, + "UrlContext": { + "additionalProperties": false, + "description": "Tool to support URL context.", + "properties": {}, + "title": "UrlContext", + "type": "object" + }, + "UserPatch": { + "properties": { + "full_name": { + "anyOf": [ + { + "maxLength": 200, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Full Name" + }, + "organization_name": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Organization Name" + } + }, + "title": "UserPatch", + "type": "object" + }, + "UserView": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "full_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Full Name" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "is_active": { + "title": "Is Active", + "type": "boolean" + }, + "is_verified": { + "title": "Is Verified", + "type": "boolean" + }, + "organization_name": { + "title": "Organization Name", + "type": "string" + }, + "role": { + "enum": [ + "owner", + "admin", + "member", + "auditor" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "id", + "email", + "full_name", + "organization_name", + "role", + "is_active", + "is_verified", + "created_at" + ], + "title": "UserView", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "VerifyResult": { + "properties": { + "anchor_mismatches": { + "items": { + "$ref": "#/components/schemas/AnchorMismatch" + }, + "title": "Anchor Mismatches", + "type": "array" + }, + "anchors_checked": { + "minimum": 0.0, + "title": "Anchors Checked", + "type": "integer" + }, + "first_break": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChainBreak" + }, + { + "type": "null" + } + ] + }, + "last_verified_seq": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Verified Seq" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "rows_checked": { + "minimum": 0.0, + "title": "Rows Checked", + "type": "integer" + } + }, + "required": [ + "ok", + "rows_checked", + "anchors_checked" + ], + "title": "VerifyResult", + "type": "object" + }, + "VertexAISearch": { + "additionalProperties": false, + "description": "Retrieve from Vertex AI Search datastore or engine for grounding.\n\ndatastore and engine are mutually exclusive. See\nhttps://cloud.google.com/products/agent-builder. This data type is not\nsupported in Gemini API.", + "properties": { + "dataStoreSpecs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/VertexAISearchDataStoreSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Specifications that define the specific DataStores to be searched, along with configurations for those data stores. This is only considered for Engines with multiple data stores. It should only be set if engine is used.", + "title": "Datastorespecs" + }, + "datastore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Fully-qualified Vertex AI Search data store resource ID. Format: `projects/{project}/locations/{location}/collections/{collection}/dataStores/{dataStore}`", + "title": "Datastore" + }, + "engine": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Fully-qualified Vertex AI Search engine resource ID. Format: `projects/{project}/locations/{location}/collections/{collection}/engines/{engine}`", + "title": "Engine" + }, + "filter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Filter strings to be passed to the search API.", + "title": "Filter" + }, + "maxResults": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. Number of search results to return per query. The default value is 10. The maximumm allowed value is 10.", + "title": "Maxresults" + } + }, + "title": "VertexAISearch", + "type": "object" + }, + "VertexAISearchDataStoreSpec": { + "additionalProperties": false, + "description": "Define data stores within engine to filter on in a search call and configurations for those data stores.\n\nFor more information, see\nhttps://cloud.google.com/generative-ai-app-builder/docs/reference/rpc/google.cloud.discoveryengine.v1#datastorespec.\nThis data type is not supported in Gemini API.", + "properties": { + "dataStore": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Full resource name of DataStore, such as Format: `projects/{project}/locations/{location}/collections/{collection}/dataStores/{dataStore}`", + "title": "Datastore" + }, + "filter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Filter specification to filter documents in the data store specified by data_store field. For more information on filtering, see [Filtering](https://cloud.google.com/generative-ai-app-builder/docs/filter-search-metadata)", + "title": "Filter" + } + }, + "title": "VertexAISearchDataStoreSpec", + "type": "object" + }, + "VertexRagStore": { + "additionalProperties": false, + "description": "Retrieve from Vertex RAG Store for grounding.\n\nThis data type is not supported in Gemini API.", + "properties": { + "ragCorpora": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. Deprecated. Please use rag_resources instead.", + "title": "Ragcorpora" + }, + "ragResources": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/VertexRagStoreRagResource" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. The representation of the rag source. It can be used to specify corpus only or ragfiles. Currently only support one corpus or multiple files from one corpus. In the future we may open up multiple corpora support.", + "title": "Ragresources" + }, + "ragRetrievalConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/RagRetrievalConfig" + }, + { + "type": "null" + } + ], + "description": "Optional. The retrieval config for the Rag query." + }, + "similarityTopK": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional. Number of top k results to return from the selected corpora.", + "title": "Similaritytopk" + }, + "storeContext": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Optional. Currently only supported for Gemini Multimodal Live API. In Gemini Multimodal Live API, if `store_context` bool is specified, Gemini will leverage it to automatically memorize the interactions between the client and Gemini, and retrieve context when needed to augment the response generation for users' ongoing and future interactions.", + "title": "Storecontext" + }, + "vectorDistanceThreshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. Only return results with vector distance smaller than the threshold.", + "title": "Vectordistancethreshold" + } + }, + "title": "VertexRagStore", + "type": "object" + }, + "VertexRagStoreRagResource": { + "additionalProperties": false, + "description": "The definition of the Rag resource.\n\nThis data type is not supported in Gemini API.", + "properties": { + "ragCorpus": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. RagCorpora resource name. Format: `projects/{project}/locations/{location}/ragCorpora/{rag_corpus}`", + "title": "Ragcorpus" + }, + "ragFileIds": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional. rag_file_id. The files should be in the same rag_corpus set in rag_corpus field.", + "title": "Ragfileids" + } + }, + "title": "VertexRagStoreRagResource", + "type": "object" + }, + "VideoMetadata": { + "additionalProperties": false, + "description": "Provides metadata for a video, including the start and end offsets for clipping and the frame rate.", + "properties": { + "endOffset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The end offset of the video.", + "title": "Endoffset" + }, + "fps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Optional. The frame rate of the video sent to the model. If not specified, the default value is 1.0. The valid range is (0.0, 24.0].", + "title": "Fps" + }, + "startOffset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The start offset of the video.", + "title": "Startoffset" + } + }, + "title": "VideoMetadata", + "type": "object" + }, + "VideoResponseFormat": { + "additionalProperties": false, + "description": "Configuration for video-specific output formatting.\n\nThis data type is not supported in Gemini API.", + "properties": { + "aspectRatio": { + "anyOf": [ + { + "$ref": "#/components/schemas/AspectRatio" + }, + { + "type": "null" + } + ], + "description": "The aspect ratio for the video output." + }, + "delivery": { + "anyOf": [ + { + "$ref": "#/components/schemas/Delivery" + }, + { + "type": "null" + } + ], + "description": "Optional. Delivery mode for the generated content." + }, + "duration": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The duration for the video output.", + "title": "Duration" + }, + "gcsUri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. The Google Cloud Storage URI to store the video output. Required for Vertex if delivery is URI.", + "title": "Gcsuri" + } + }, + "title": "VideoResponseFormat", + "type": "object" + }, + "VoiceConfig": { + "additionalProperties": false, + "description": "The configuration for the voice to use.", + "properties": { + "prebuiltVoiceConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/PrebuiltVoiceConfig" + }, + { + "type": "null" + } + ], + "description": "The configuration for a prebuilt voice." + }, + "replicatedVoiceConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReplicatedVoiceConfig" + }, + { + "type": "null" + } + ], + "description": "The configuration for a replicated voice, which is a clone of a\n user's voice that can be used for speech synthesis. If this is unset, a\n default voice is used." + } + }, + "title": "VoiceConfig", + "type": "object" + }, + "VoiceConsentSignature": { + "additionalProperties": false, + "description": "The signature of the voice consent check.", + "properties": { + "signature": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The signature string.\n ", + "title": "Signature" + } + }, + "title": "VoiceConsentSignature", + "type": "object" + }, + "WebSearch": { + "additionalProperties": false, + "description": "Standard web search for grounding and related configurations.\n\nOnly text results are returned.", + "properties": {}, + "title": "WebSearch", + "type": "object" + }, + "WordInfo": { + "additionalProperties": false, + "description": "Information about a single recognized word.", + "properties": { + "endOffset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "End offset in time of the word relative to the start of the audio.\n ", + "title": "Endoffset" + }, + "startOffset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Start offset in time of the word relative to the start of the audio.\n ", + "title": "Startoffset" + }, + "word": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Transcript of the word.\n ", + "title": "Word" + } + }, + "title": "WordInfo", + "type": "object" + } + }, + "securitySchemes": { + "AnthropicAPIKey": { + "in": "header", + "name": "x-api-key", + "type": "apiKey" + }, + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + }, + "ShimAPIKey": { + "in": "header", + "name": "x-shim-key", + "type": "apiKey" + } + } + }, + "info": { + "title": "shim trust-boundary gateway", + "version": "0.1.3" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/auth/callback": { + "get": { + "operationId": "callback_api_v1_auth_callback_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Callback", + "tags": [ + "identity" + ] + } + }, + "/api/v1/auth/login": { + "get": { + "operationId": "login_api_v1_auth_login_get", + "parameters": [ + { + "in": "query", + "name": "next", + "required": false, + "schema": { + "default": "/dashboard", + "title": "Next", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login", + "tags": [ + "identity" + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "logout_api_v1_auth_logout_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Logout", + "tags": [ + "identity" + ] + } + }, + "/api/v1/auth/session": { + "get": { + "operationId": "get_session_api_v1_auth_session_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Session Api V1 Auth Session Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Session", + "tags": [ + "identity" + ] + } + }, + "/api/v1/compliance/audit/anchor": { + "post": { + "operationId": "trigger_anchor_api_v1_compliance_audit_anchor_post", + "parameters": [ + { + "in": "query", + "name": "anchor_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Anchor Date" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnchorResult" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Trigger Anchor", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/audit/logs": { + "get": { + "operationId": "list_audit_logs_api_v1_compliance_audit_logs_get", + "parameters": [ + { + "in": "query", + "name": "request_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Id" + } + }, + { + "in": "query", + "name": "event_type", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Event Type" + } + }, + { + "in": "query", + "name": "start", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "in": "query", + "name": "end", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditLogPage" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Audit Logs", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/audit/verify": { + "post": { + "operationId": "verify_audit_chain_api_v1_compliance_audit_verify_post", + "parameters": [ + { + "in": "query", + "name": "from", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "in": "query", + "name": "to", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "To" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyResult" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Verify Audit Chain", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/connectors": { + "get": { + "operationId": "list_connectors_api_v1_compliance_connectors_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ConnectorRead" + }, + "title": "Response List Connectors Api V1 Compliance Connectors Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Connectors", + "tags": [ + "compliance" + ] + }, + "post": { + "operationId": "create_connector_api_v1_compliance_connectors_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectorCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectorRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Connector", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/compliance/connectors/{connector_id}": { + "delete": { + "operationId": "delete_connector_api_v1_compliance_connectors__connector_id__delete", + "parameters": [ + { + "in": "path", + "name": "connector_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Connector", + "tags": [ + "compliance" + ] + }, + "get": { + "operationId": "get_connector_api_v1_compliance_connectors__connector_id__get", + "parameters": [ + { + "in": "path", + "name": "connector_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectorRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Connector", + "tags": [ + "compliance" + ] + }, + "patch": { + "operationId": "update_connector_api_v1_compliance_connectors__connector_id__patch", + "parameters": [ + { + "in": "path", + "name": "connector_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectorUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectorRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Connector", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/compliance/connectors/{connector_id}/run": { + "post": { + "operationId": "run_connector_api_v1_compliance_connectors__connector_id__run_post", + "parameters": [ + { + "in": "path", + "name": "connector_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunResult" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Run Connector", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/compliance/findings": { + "get": { + "operationId": "list_findings_api_v1_compliance_findings_get", + "parameters": [ + { + "in": "query", + "name": "connector_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connector Id" + } + }, + { + "in": "query", + "name": "severity", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Severity" + } + }, + { + "in": "query", + "name": "entity_type", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Entity Type" + } + }, + { + "in": "query", + "name": "actor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actor" + } + }, + { + "in": "query", + "name": "start", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "in": "query", + "name": "end", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindingPage" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Findings", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/compliance/findings/summary": { + "get": { + "operationId": "findings_summary_api_v1_compliance_findings_summary_get", + "parameters": [ + { + "in": "query", + "name": "connector_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connector Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindingSummary" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Findings Summary", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/compliance/forward-targets": { + "get": { + "operationId": "list_forward_targets_api_v1_compliance_forward_targets_get", + "parameters": [ + { + "in": "query", + "name": "connector_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connector Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ForwardTargetRead" + }, + "title": "Response List Forward Targets Api V1 Compliance Forward Targets Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Forward Targets", + "tags": [ + "compliance" + ] + }, + "post": { + "operationId": "create_forward_target_api_v1_compliance_forward_targets_post", + "parameters": [ + { + "in": "query", + "name": "connector_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connector Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForwardTargetCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForwardTargetRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Forward Target", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/compliance/forward-targets/{target_id}": { + "delete": { + "operationId": "delete_forward_target_api_v1_compliance_forward_targets__target_id__delete", + "parameters": [ + { + "in": "path", + "name": "target_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Target Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Forward Target", + "tags": [ + "compliance" + ] + }, + "patch": { + "operationId": "update_forward_target_api_v1_compliance_forward_targets__target_id__patch", + "parameters": [ + { + "in": "path", + "name": "target_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Target Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForwardTargetUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForwardTargetRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Forward Target", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/compliance/oversight": { + "get": { + "operationId": "list_oversight_requests_api_v1_compliance_oversight_get", + "parameters": [ + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "pending", + "approved", + "rejected", + "expired" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/OversightRequestRead" + }, + "title": "Response List Oversight Requests Api V1 Compliance Oversight Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Oversight Requests", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/oversight/evaluate": { + "post": { + "operationId": "trigger_oversight_evaluation_api_v1_compliance_oversight_evaluate_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Trigger Oversight Evaluation Api V1 Compliance Oversight Evaluate Post", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Trigger Oversight Evaluation", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/oversight/policies": { + "get": { + "operationId": "list_oversight_policies_api_v1_compliance_oversight_policies_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/OversightPolicyRead" + }, + "title": "Response List Oversight Policies Api V1 Compliance Oversight Policies Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Oversight Policies", + "tags": [ + "ai-act-control-plane" + ] + }, + "post": { + "operationId": "create_oversight_policy_api_v1_compliance_oversight_policies_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OversightPolicyCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OversightPolicyRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Oversight Policy", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/oversight/policies/{policy_id}": { + "delete": { + "operationId": "delete_oversight_policy_api_v1_compliance_oversight_policies__policy_id__delete", + "parameters": [ + { + "in": "path", + "name": "policy_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Policy Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Oversight Policy", + "tags": [ + "ai-act-control-plane" + ] + }, + "patch": { + "operationId": "update_oversight_policy_api_v1_compliance_oversight_policies__policy_id__patch", + "parameters": [ + { + "in": "path", + "name": "policy_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Policy Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OversightPolicyUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OversightPolicyRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Oversight Policy", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/oversight/{request_id}/decision": { + "post": { + "operationId": "decide_oversight_request_api_v1_compliance_oversight__request_id__decision_post", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OversightDecision" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OversightRequestRead" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Decide Oversight Request", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/overview": { + "get": { + "operationId": "compliance_overview_api_v1_compliance_overview_get", + "parameters": [ + { + "in": "query", + "name": "start", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "in": "query", + "name": "end", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OverviewResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Compliance Overview", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/reports/audit": { + "post": { + "operationId": "generate_audit_report_endpoint_api_v1_compliance_reports_audit_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditReportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "format": "binary", + "type": "string" + } + }, + "text/csv": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "PDF or CSV report attachment.", + "headers": { + "Content-Disposition": { + "description": "Attachment disposition and filename.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Generate Audit Report Endpoint", + "tags": [ + "ai-act-control-plane" + ] + } + }, + "/api/v1/compliance/reports/kvkk": { + "post": { + "operationId": "generate_kvkk_report_api_v1_compliance_reports_kvkk_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "format": "binary", + "type": "string" + } + }, + "text/csv": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "PDF or CSV report attachment.", + "headers": { + "Content-Disposition": { + "description": "Attachment disposition and filename.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Generate Kvkk Report", + "tags": [ + "compliance" + ] + } + }, + "/api/v1/management/api-keys": { + "get": { + "operationId": "list_api_keys_api_v1_management_api_keys_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ApiKeyView" + }, + "title": "Response List Api Keys Api V1 Management Api Keys Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Api Keys", + "tags": [ + "management" + ] + }, + "post": { + "operationId": "create_api_key_api_v1_management_api_keys_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedApiKey" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Api Key", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/api-keys/{api_key_id}": { + "delete": { + "operationId": "revoke_api_key_api_v1_management_api_keys__api_key_id__delete", + "parameters": [ + { + "in": "path", + "name": "api_key_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Api Key Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Revoke Api Key", + "tags": [ + "management" + ] + }, + "patch": { + "operationId": "update_api_key_api_v1_management_api_keys__api_key_id__patch", + "parameters": [ + { + "in": "path", + "name": "api_key_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Api Key Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Api Key", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/api-keys/{api_key_id}/rotate": { + "post": { + "operationId": "rotate_api_key_api_v1_management_api_keys__api_key_id__rotate_post", + "parameters": [ + { + "in": "path", + "name": "api_key_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Api Key Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedApiKey" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Rotate Api Key", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/auth/me": { + "get": { + "operationId": "current_profile_api_v1_management_auth_me_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserView" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Current Profile", + "tags": [ + "management" + ] + }, + "put": { + "operationId": "update_profile_api_v1_management_auth_me_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Profile", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/billing/breakdown": { + "get": { + "operationId": "billing_breakdown_api_v1_management_billing_breakdown_get", + "parameters": [ + { + "description": "Inclusive timestamp; values without an offset are UTC.", + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Inclusive timestamp; values without an offset are UTC.", + "title": "Start Date" + } + }, + { + "description": "Inclusive timestamp; values without an offset are UTC.", + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Inclusive timestamp; values without an offset are UTC.", + "title": "End Date" + } + }, + { + "description": "Tag grouping counts a multi-tag request once in each matching row.", + "in": "query", + "name": "group_by", + "required": false, + "schema": { + "default": "model", + "description": "Tag grouping counts a multi-tag request once in each matching row.", + "enum": [ + "model", + "tag", + "cost_center", + "provider", + "team" + ], + "title": "Group By", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingBreakdownView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Billing Breakdown", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/billing/export": { + "get": { + "operationId": "export_billing_breakdown_api_v1_management_billing_export_get", + "parameters": [ + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "group_by", + "required": false, + "schema": { + "default": "model", + "enum": [ + "model", + "tag", + "cost_center", + "provider", + "team" + ], + "title": "Group By", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "default": "csv", + "enum": [ + "csv", + "pdf" + ], + "title": "Format", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "format": "binary", + "type": "string" + } + }, + "text/csv": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "CSV or PDF attachment.", + "headers": { + "Content-Disposition": { + "description": "Attachment disposition and filename.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Export Billing Breakdown", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/billing/usage": { + "get": { + "operationId": "billing_usage_api_v1_management_billing_usage_get", + "parameters": [ + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingUsageView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Billing Usage", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/cloud-billing": { + "get": { + "operationId": "billing_status_api_v1_management_cloud_billing_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudBillingView" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Billing Status", + "tags": [ + "cloud billing" + ] + } + }, + "/api/v1/management/cloud-billing/checkout": { + "post": { + "operationId": "checkout_api_v1_management_cloud_billing_checkout_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckoutRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingOperationView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Checkout", + "tags": [ + "cloud billing" + ] + } + }, + "/api/v1/management/cloud-billing/operations/{operation_id}": { + "get": { + "operationId": "operation_status_api_v1_management_cloud_billing_operations__operation_id__get", + "parameters": [ + { + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Operation Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingOperationView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Operation Status", + "tags": [ + "cloud billing" + ] + } + }, + "/api/v1/management/cloud-billing/portal": { + "post": { + "operationId": "portal_api_v1_management_cloud_billing_portal_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingOperationView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Portal", + "tags": [ + "cloud billing" + ] + } + }, + "/api/v1/management/cost/budgets": { + "get": { + "operationId": "list_budgets_api_v1_management_cost_budgets_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BudgetView" + }, + "title": "Response List Budgets Api V1 Management Cost Budgets Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Budgets", + "tags": [ + "management" + ] + }, + "post": { + "operationId": "create_budget_api_v1_management_cost_budgets_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Budget", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/cost/budgets/evaluate": { + "post": { + "operationId": "evaluate_budgets_api_v1_management_cost_budgets_evaluate_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetEvaluationView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "description": "Budget configuration or synchronous evaluation limit rejected." + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Evaluate Budgets", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/cost/budgets/{budget_id}": { + "delete": { + "operationId": "delete_budget_api_v1_management_cost_budgets__budget_id__delete", + "parameters": [ + { + "in": "path", + "name": "budget_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Budget Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Budget", + "tags": [ + "management" + ] + }, + "patch": { + "operationId": "update_budget_api_v1_management_cost_budgets__budget_id__patch", + "parameters": [ + { + "in": "path", + "name": "budget_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Budget Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Budget", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/model-deployments": { + "get": { + "operationId": "list_model_deployments_api_v1_management_model_deployments_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ModelDeploymentView" + }, + "title": "Response List Model Deployments Api V1 Management Model Deployments Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Model Deployments", + "tags": [ + "management" + ] + }, + "post": { + "operationId": "create_model_deployment_api_v1_management_model_deployments_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelDeploymentInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelDeploymentView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Model Deployment", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/model-deployments/{deployment_id}": { + "put": { + "operationId": "update_model_deployment_api_v1_management_model_deployments__deployment_id__put", + "parameters": [ + { + "in": "path", + "name": "deployment_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Deployment Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelDeploymentInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelDeploymentView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Model Deployment", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/model-deployments/{deployment_id}/health": { + "post": { + "operationId": "check_model_deployment_health_api_v1_management_model_deployments__deployment_id__health_post", + "parameters": [ + { + "in": "path", + "name": "deployment_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Deployment Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelDeploymentView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Check Model Deployment Health", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/overview": { + "get": { + "operationId": "dashboard_overview_api_v1_management_overview_get", + "parameters": [ + { + "description": "Inclusive UTC start; defaults to seven days before end.", + "in": "query", + "name": "start", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Inclusive UTC start; defaults to seven days before end.", + "title": "Start" + } + }, + { + "description": "Exclusive UTC end; defaults to the current time.", + "in": "query", + "name": "end", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Exclusive UTC end; defaults to the current time.", + "title": "End" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OverviewDashboardView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Dashboard Overview", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/providers": { + "get": { + "operationId": "list_provider_secrets_api_v1_management_providers_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProviderSecretView" + }, + "title": "Response List Provider Secrets Api V1 Management Providers Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Provider Secrets", + "tags": [ + "management" + ] + }, + "post": { + "operationId": "create_provider_secret_api_v1_management_providers_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderSecretInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderSecretView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Provider Secret", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/providers/{secret_id}": { + "delete": { + "operationId": "delete_provider_secret_api_v1_management_providers__secret_id__delete", + "parameters": [ + { + "in": "path", + "name": "secret_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Secret Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Provider Secret", + "tags": [ + "management" + ] + }, + "put": { + "operationId": "update_provider_secret_api_v1_management_providers__secret_id__put", + "parameters": [ + { + "in": "path", + "name": "secret_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Secret Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderSecretPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderSecretView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Provider Secret", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/providers/{secret_id}/verify": { + "post": { + "operationId": "verify_provider_secret_api_v1_management_providers__secret_id__verify_post", + "parameters": [ + { + "in": "path", + "name": "secret_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Secret Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderSecretView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Verify Provider Secret", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/requests": { + "get": { + "operationId": "list_requests_api_v1_management_requests_get", + "parameters": [ + { + "description": "Inclusive timestamp; values without an offset are UTC.", + "in": "query", + "name": "start", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Inclusive timestamp; values without an offset are UTC.", + "title": "Start" + } + }, + { + "description": "Inclusive timestamp; values without an offset are UTC.", + "in": "query", + "name": "end", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Inclusive timestamp; values without an offset are UTC.", + "title": "End" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "completed", + "provider_error", + "client_disconnected", + "timeout", + "cancelled", + "internal_error", + "rejected", + "failed", + "unknown" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "description": "Case-insensitive model substring.", + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive model substring.", + "title": "Model" + } + }, + { + "in": "query", + "name": "request_id", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Id" + } + }, + { + "in": "query", + "name": "pii_detected", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pii Detected" + } + }, + { + "description": "Case-insensitive substring of any request tag.", + "in": "query", + "name": "tag", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive substring of any request tag.", + "title": "Tag" + } + }, + { + "description": "Case-insensitive cost center substring.", + "in": "query", + "name": "cost_center", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive cost center substring.", + "title": "Cost Center" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestActivityPage" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Requests", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/requests/export": { + "get": { + "operationId": "export_requests_api_v1_management_requests_export_get", + "parameters": [ + { + "in": "query", + "name": "start", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "in": "query", + "name": "end", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "completed", + "provider_error", + "client_disconnected", + "timeout", + "cancelled", + "internal_error", + "rejected", + "failed", + "unknown" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "description": "Case-insensitive model substring.", + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive model substring.", + "title": "Model" + } + }, + { + "in": "query", + "name": "request_id", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Id" + } + }, + { + "in": "query", + "name": "pii_detected", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pii Detected" + } + }, + { + "description": "Case-insensitive substring of any request tag.", + "in": "query", + "name": "tag", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive substring of any request tag.", + "title": "Tag" + } + }, + { + "description": "Case-insensitive cost center substring.", + "in": "query", + "name": "cost_center", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive cost center substring.", + "title": "Cost Center" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "CSV attachment.", + "headers": { + "Content-Disposition": { + "description": "Attachment disposition and filename.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Export Requests", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/settings/pii": { + "get": { + "operationId": "get_privacy_settings_api_v1_management_settings_pii_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivacySettings" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Privacy Settings", + "tags": [ + "management" + ] + }, + "put": { + "operationId": "update_privacy_settings_api_v1_management_settings_pii_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivacyPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivacySettings" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Privacy Settings", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/subscription": { + "get": { + "operationId": "get_subscription_api_v1_management_subscription_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionView" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Subscription", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/team/invites": { + "get": { + "operationId": "list_team_invites_api_v1_management_team_invites_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TeamInviteView" + }, + "title": "Response List Team Invites Api V1 Management Team Invites Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Team Invites", + "tags": [ + "management" + ] + }, + "post": { + "operationId": "create_team_invite_api_v1_management_team_invites_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamInviteInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedTeamInvite" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Team Invite", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/team/invites/accept": { + "post": { + "operationId": "accept_team_invite_api_v1_management_team_invites_accept_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcceptTeamInvite" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamMemberView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Accept Team Invite", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/team/invites/{invite_id}": { + "delete": { + "operationId": "revoke_team_invite_api_v1_management_team_invites__invite_id__delete", + "parameters": [ + { + "in": "path", + "name": "invite_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Invite Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Revoke Team Invite", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/team/members": { + "get": { + "operationId": "list_team_members_api_v1_management_team_members_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TeamMemberView" + }, + "title": "Response List Team Members Api V1 Management Team Members Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Team Members", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/team/members/{member_id}": { + "delete": { + "operationId": "remove_team_member_api_v1_management_team_members__member_id__delete", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Member Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Remove Team Member", + "tags": [ + "management" + ] + }, + "patch": { + "operationId": "update_team_member_api_v1_management_team_members__member_id__patch", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Member Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamRolePatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamMemberView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Team Member", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/teams": { + "get": { + "operationId": "list_teams_api_v1_management_teams_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TeamView" + }, + "title": "Response List Teams Api V1 Management Teams Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Teams", + "tags": [ + "management" + ] + }, + "post": { + "operationId": "create_team_api_v1_management_teams_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Team", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/teams/{team_id}": { + "put": { + "operationId": "update_team_api_v1_management_teams__team_id__put", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Team Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Team", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/teams/{team_id}/members": { + "get": { + "operationId": "list_memberships_api_v1_management_teams__team_id__members_get", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Team Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MembershipView" + }, + "title": "Response List Memberships Api V1 Management Teams Team Id Members Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Memberships", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/teams/{team_id}/members/{member_id}": { + "delete": { + "operationId": "remove_membership_api_v1_management_teams__team_id__members__member_id__delete", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Team Id", + "type": "string" + } + }, + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Member Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Remove Membership", + "tags": [ + "management" + ] + }, + "put": { + "operationId": "set_membership_api_v1_management_teams__team_id__members__member_id__put", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Team Id", + "type": "string" + } + }, + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Member Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Set Membership", + "tags": [ + "management" + ] + } + }, + "/api/v1/management/tier-info": { + "get": { + "operationId": "tier_info_api_v1_management_tier_info_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TierView" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Tier Info", + "tags": [ + "management" + ] + } + }, + "/api/v1/shared-results/{token}": { + "get": { + "operationId": "view_shared_result_api_v1_shared_results__token__get", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "title": "Token", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedResultView" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "View Shared Result", + "tags": [ + "shared-results" + ] + } + }, + "/api/v1/webhooks/polar": { + "post": { + "operationId": "polar_webhook_api_v1_webhooks_polar_post", + "responses": { + "204": { + "description": "Successful Response" + } + }, + "summary": "Polar Webhook", + "tags": [ + "cloud billing" + ] + } + }, + "/health": { + "get": { + "operationId": "_health_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Health Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": " Health", + "tags": [ + "operations" + ] + } + }, + "/v1/chat/completions": { + "post": { + "operationId": "chat_completions_v1_chat_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + }, + "description": "Native Chat Completion JSON or server-sent events." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "529": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Chat Completions" + } + }, + "/v1/messages": { + "post": { + "operationId": "messages_v1_messages_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessagesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + }, + "description": "Native Anthropic Message JSON or server-sent events." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "529": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + }, + { + "AnthropicAPIKey": [] + } + ], + "summary": "Messages" + } + }, + "/v1/messages/count_tokens": { + "post": { + "operationId": "count_tokens_v1_messages_count_tokens_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CountTokensRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + }, + "529": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + } + }, + "description": "Sanitized Anthropic-compatible error." + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + }, + { + "AnthropicAPIKey": [] + } + ], + "summary": "Count Tokens" + } + }, + "/v1/models": { + "get": { + "operationId": "list_models_v1_models_get", + "parameters": [ + { + "in": "query", + "name": "client_version", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Version" + } + }, + { + "in": "query", + "name": "after_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After Id" + } + }, + { + "in": "query", + "name": "before_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Before Id" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "header", + "name": "anthropic-version", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Anthropic-Version" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelListView" + }, + { + "$ref": "#/components/schemas/CodexModelListView" + }, + { + "$ref": "#/components/schemas/AnthropicModelListView" + } + ], + "title": "Response List Models V1 Models Get" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 400 List Models V1 Models Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "401": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 401 List Models V1 Models Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 403 List Models V1 Models Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "404": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 404 List Models V1 Models Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "422": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 422 List Models V1 Models Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "429": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 429 List Models V1 Models Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "503": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 503 List Models V1 Models Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + }, + { + "AnthropicAPIKey": [] + } + ], + "summary": "List Models" + } + }, + "/v1/models/{model_id}": { + "get": { + "operationId": "retrieve_model_v1_models__model_id__get", + "parameters": [ + { + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "title": "Model Id", + "type": "string" + } + }, + { + "in": "header", + "name": "anthropic-version", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Anthropic-Version" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelRecordView" + }, + { + "$ref": "#/components/schemas/AnthropicModelRecordView" + } + ], + "title": "Response Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 400 Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "401": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 401 Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 403 Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "404": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 404 Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "422": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 422 Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "429": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 429 Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + }, + "503": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpenAIErrorResponse" + }, + { + "$ref": "#/components/schemas/AnthropicErrorResponse" + } + ], + "title": "Response 503 Retrieve Model V1 Models Model Id Get" + } + } + }, + "description": "OpenAI- or Anthropic-compatible error selected by headers." + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + }, + { + "AnthropicAPIKey": [] + } + ], + "summary": "Retrieve Model" + } + }, + "/v1/responses": { + "post": { + "operationId": "responses_v1_responses_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponsesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + }, + "description": "Native OpenAI Response JSON or server-sent events." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + }, + "529": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "Sanitized OpenAI-compatible error." + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Responses" + } + }, + "/v1/scan": { + "post": { + "operationId": "scan_text_v1_scan_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Scan Text", + "tags": [ + "scan" + ] + } + }, + "/v1/scan/usage": { + "get": { + "operationId": "scan_usage_v1_scan_usage_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanUsageResponse" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Scan Usage", + "tags": [ + "scan" + ] + } + }, + "/v1/shared-results": { + "post": { + "operationId": "create_shared_result_v1_shared_results_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedResultCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedResultCreated" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Create Shared Result", + "tags": [ + "shared-results" + ] + } + }, + "/v1beta/models/{model}:generateContent": { + "post": { + "operationId": "generate_content_v1beta_models__model__generateContent_post", + "parameters": [ + { + "in": "path", + "name": "model", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "title": "Model", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateContentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Generate Content" + } + }, + "/v1beta/models/{model}:streamGenerateContent": { + "post": { + "operationId": "stream_generate_content_v1beta_models__model__streamGenerateContent_post", + "parameters": [ + { + "in": "path", + "name": "model", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "title": "Model", + "type": "string" + } + }, + { + "in": "query", + "name": "alt", + "required": true, + "schema": { + "const": "sse", + "title": "Alt", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateContentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + }, + "description": "Native Gemini server-sent events." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ShimAPIKey": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Stream Generate Content" + } + } + } +} diff --git a/ee/cloud/pyproject.toml b/ee/cloud/pyproject.toml new file mode 100644 index 0000000..a9b02f9 --- /dev/null +++ b/ee/cloud/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "shim-cloud" +version = "0.1.3" +description = "Hosted cloud commerce composition for shim" +license = "Elastic-2.0" +license-files = ["LICENSE", "NOTICE"] +requires-python = ">=3.13,<3.14" +dependencies = [ + "shim-enterprise==0.1.3", + "polar-sdk==0.32.0", + "standardwebhooks==1.0.0", +] + +[tool.uv.build-backend] +module-name = "shim_cloud" +module-root = "src" + +[build-system] +requires = ["uv_build>=0.12.5,<0.13"] +build-backend = "uv_build" diff --git a/ee/cloud/src/shim_cloud/__init__.py b/ee/cloud/src/shim_cloud/__init__.py new file mode 100644 index 0000000..f070da7 --- /dev/null +++ b/ee/cloud/src/shim_cloud/__init__.py @@ -0,0 +1 @@ +"""Cloud-only commerce; shared enterprise runtimes never import this package.""" diff --git a/ee/cloud/src/shim_cloud/api.py b/ee/cloud/src/shim_cloud/api.py new file mode 100644 index 0000000..fe8adec --- /dev/null +++ b/ee/cloud/src/shim_cloud/api.py @@ -0,0 +1,274 @@ +"""Owner-scoped cloud billing routes and signed webhook intake.""" + +from datetime import datetime, timezone +from hashlib import sha256 +from typing import Literal +from uuid import UUID + +from cryptography.fernet import InvalidToken +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, ConfigDict, ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from standardwebhooks import Webhook +from standardwebhooks.webhooks import WebhookVerificationError + +from shim_enterprise.api.enterprise_deps import get_current_user, get_org_owner +from shim_enterprise.core.database import get_db +from shim_enterprise.outbox.publisher import OutboxIdentityConflict +from shim_enterprise.tenants.models import User +from shim_enterprise.tenants.plans import organization_plan +from shim_cloud.billing import ( + SYNC_EVENT, + append_intent, + request_operation, + result_cipher, +) +from shim_cloud.config import CloudSettings +from shim_cloud.models import BillingOperation + +router = APIRouter(tags=["cloud billing"]) + + +class ProductChoice(BaseModel): + plan: Literal["managed", "agency"] + interval: Literal["monthly", "yearly"] + + +class CloudBillingView(BaseModel): + plan: str + status: str + source: str | None + current_period_end: datetime | None + cancel_at_period_end: bool + can_manage: bool + can_checkout: bool + can_open_portal: bool + products: list[ProductChoice] + + +class OperationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + request_id: UUID + + +class CheckoutRequest(OperationRequest): + plan: Literal["managed", "agency"] + interval: Literal["monthly", "yearly"] + + +class BillingOperationView(BaseModel): + id: UUID + status: Literal["pending", "processing", "complete", "failed", "expired"] + url: str | None = None + error: str | None = None + + +def cloud_settings(request: Request) -> CloudSettings: + return request.app.state.cloud_settings + + +def operation_view(operation: BillingOperation) -> BillingOperationView: + if operation.expires_at <= datetime.now(timezone.utc): + return BillingOperationView(id=operation.id, status="expired") + url = None + if operation.result_ciphertext is not None and operation.status == "complete": + try: + url = result_cipher().decrypt(operation.result_ciphertext.encode()).decode() + except InvalidToken: + return BillingOperationView(id=operation.id, status="expired") + return BillingOperationView.model_validate( + { + "id": operation.id, + "status": operation.status, + "url": url, + "error": operation.error, + } + ) + + +@router.get("/management/cloud-billing", response_model=CloudBillingView) +async def billing_status( + response: Response, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), + config: CloudSettings = Depends(cloud_settings), +) -> CloudBillingView: + response.headers["Cache-Control"] = "no-store" + plan = await organization_plan(session, user.organization_id) + manageable = user.role == "owner" and ( + plan.source == "polar" or (plan.source is None and plan.tier == "free") + ) + can_checkout = ( + manageable and plan.tier == "free" and plan.status != "review_required" + ) + return CloudBillingView( + plan=plan.tier, + status=plan.status, + source=plan.source, + current_period_end=plan.current_period_end, + cancel_at_period_end=plan.cancel_at_period_end, + can_manage=manageable, + can_checkout=can_checkout, + can_open_portal=manageable + and plan.source == "polar" + and plan.customer_id is not None, + products=[ + ProductChoice.model_validate( + dict(zip(("plan", "interval"), choice.split(":"))) + ) + for choice in sorted(config.POLAR_PRODUCTS) + ] + if can_checkout + else [], + ) + + +@router.post( + "/management/cloud-billing/checkout", + response_model=BillingOperationView, + status_code=202, +) +async def checkout( + body: CheckoutRequest, + response: Response, + user: User = Depends(get_org_owner), + session: AsyncSession = Depends(get_db), + config: CloudSettings = Depends(cloud_settings), +) -> BillingOperationView: + product_id = config.POLAR_PRODUCTS.get(f"{body.plan}:{body.interval}") + if product_id is None: + raise HTTPException(422, "This subscription option is unavailable") + return await _request(body, response, user, session, "checkout", str(product_id)) + + +@router.post( + "/management/cloud-billing/portal", + response_model=BillingOperationView, + status_code=202, +) +async def portal( + body: OperationRequest, + response: Response, + user: User = Depends(get_org_owner), + session: AsyncSession = Depends(get_db), +) -> BillingOperationView: + return await _request(body, response, user, session, "portal", None) + + +async def _request( + body: OperationRequest, + response: Response, + user: User, + session: AsyncSession, + kind: str, + product_id: str | None, +) -> BillingOperationView: + response.headers["Cache-Control"] = "no-store" + try: + operation = await request_operation( + session, + user.organization_id, + user.id, + request_id=body.request_id, + kind=kind, + product_id=product_id, + ) + await session.commit() + except ValueError as exc: + await session.rollback() + raise HTTPException(409, str(exc)) from exc + return operation_view(operation) + + +@router.get( + "/management/cloud-billing/operations/{operation_id}", + response_model=BillingOperationView, +) +async def operation_status( + operation_id: UUID, + response: Response, + user: User = Depends(get_org_owner), + session: AsyncSession = Depends(get_db), +) -> BillingOperationView: + response.headers["Cache-Control"] = "no-store" + operation = await session.scalar( + select(BillingOperation).where( + BillingOperation.id == operation_id, + BillingOperation.organization_id == user.organization_id, + BillingOperation.created_by == user.id, + ) + ) + if operation is None: + raise HTTPException(404, "Billing operation not found") + return operation_view(operation) + + +class WebhookCustomer(BaseModel): + id: UUID + external_id: UUID | None = None + organization_id: UUID + + +class CustomerStateEvent(BaseModel): + type: Literal["customer.state_changed"] + data: WebhookCustomer + + +@router.post("/webhooks/polar", status_code=204, response_class=Response) +async def polar_webhook( + request: Request, + session: AsyncSession = Depends(get_db), + config: CloudSettings = Depends(cloud_settings), +) -> Response: + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > 256 * 1024: + raise HTTPException(413, "Webhook payload too large") + try: + event = Webhook(config.POLAR_WEBHOOK_SECRET.get_secret_value()).verify( + bytes(body), dict(request.headers) + ) + except (WebhookVerificationError, ValueError): + raise HTTPException(403, "Invalid webhook signature") from None + if not isinstance(event, dict): + raise HTTPException(422, "Invalid webhook payload") + if event.get("type") != "customer.state_changed": + return Response(status_code=204) + try: + parsed = CustomerStateEvent.model_validate(event) + except ValidationError: + raise HTTPException(422, "Invalid customer state event") from None + if parsed.data.organization_id != config.POLAR_ORGANIZATION_ID: + raise HTTPException(403, "Webhook merchant mismatch") + if parsed.data.external_id is None: + return Response(status_code=204) + try: + plan = await organization_plan(session, parsed.data.external_id) + except ValueError: + return Response(status_code=204) + if plan.source != "polar": + return Response(status_code=204) + if plan.customer_id not in {None, str(parsed.data.id)}: + raise HTTPException(403, "Webhook customer mismatch") + webhook_id = request.headers["webhook-id"] + if not 1 <= len(webhook_id) <= 200: + raise HTTPException(422, "Invalid webhook identity") + try: + await append_intent( + session, + plan.organization_id, + event_type=SYNC_EVENT, + aggregate_id=str(plan.organization_id), + idempotency_key=f"polar:webhook:{webhook_id}", + payload={ + "customer_id": str(parsed.data.id), + "digest": sha256(body).hexdigest(), + }, + ) + await session.commit() + except OutboxIdentityConflict: + await session.rollback() + raise HTTPException(409, "Webhook identity conflict") from None + return Response(status_code=204) diff --git a/ee/cloud/src/shim_cloud/application.py b/ee/cloud/src/shim_cloud/application.py new file mode 100644 index 0000000..4392e54 --- /dev/null +++ b/ee/cloud/src/shim_cloud/application.py @@ -0,0 +1,33 @@ +"""Cloud composition adds commerce to the licensed enterprise application.""" + +from fastapi import Depends, FastAPI, Request, Security +from fastapi.security import HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession + +from shim_enterprise.api.enterprise_deps import bearer_scheme, get_current_user +from shim_enterprise.application import create_enterprise_app +from shim_enterprise.core.config import settings +from shim_enterprise.core.database import get_db +from shim_enterprise.tenants.models import User +from shim_enterprise.tenants.plans import configure_organization_quota +from shim_cloud.api import router +from shim_cloud.config import CloudSettings + + +async def cloud_user( + request: Request, + bearer: HTTPAuthorizationCredentials | None = Security(bearer_scheme), + session: AsyncSession = Depends(get_db), +) -> User: + user = await get_current_user(request, bearer, session) + await configure_organization_quota(session, user.organization_id) + await session.commit() + return user + + +def create_cloud_app(config: CloudSettings | None = None) -> FastAPI: + application = create_enterprise_app() + application.state.cloud_settings = config or CloudSettings() + application.dependency_overrides[get_current_user] = cloud_user + application.include_router(router, prefix=settings.API_PREFIX) + return application diff --git a/ee/cloud/src/shim_cloud/billing.py b/ee/cloud/src/shim_cloud/billing.py new file mode 100644 index 0000000..7e96dc1 --- /dev/null +++ b/ee/cloud/src/shim_cloud/billing.py @@ -0,0 +1,323 @@ +"""Committed commerce intents and verified entitlement synchronization.""" + +import base64 +from datetime import datetime, timedelta, timezone +from hashlib import sha256 +from urllib.parse import urlsplit +from uuid import UUID + +from cryptography.fernet import Fernet +from polar_sdk import Polar, PolarError +import httpx +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from shim.gateway.contracts.ids import TenantId +from shim_enterprise.core.config import settings +from shim_enterprise.core.database import AsyncSessionLocal +from shim_enterprise.outbox.publisher import OutboxMessage, OutboxWriter +from shim_enterprise.tenants.plans import ( + apply_billing_plan, + billing_owner_email, + claim_billing_source, + organization_plan, +) +from shim_cloud.config import CloudSettings +from shim_cloud.models import BillingOperation +from shim_cloud.polar import checkout_url, customer_state, portal_url, validate_catalog + +OPERATION_EVENT = "cloud.billing_operation" +SYNC_EVENT = "cloud.billing_sync" +OPERATION_LIFETIME = timedelta(minutes=10) + + +def result_cipher() -> Fernet: + # Domain separation keeps these transient bearer URLs apart from other secrets. + key = sha256(b"shim-cloud-billing-result\0" + settings.SECRET_KEY.encode()).digest() + return Fernet(base64.urlsafe_b64encode(key)) + + +async def append_intent( + session: AsyncSession, + organization_id: UUID, + *, + event_type: str, + aggregate_id: str, + idempotency_key: str, + payload: dict[str, str], +) -> None: + await OutboxWriter().append( + session, + organization_id=TenantId(organization_id), + values={ + "event_type": event_type, + "aggregate_type": "cloud_billing", + "aggregate_id": aggregate_id, + "idempotency_key": idempotency_key, + "payload": payload, + "next_attempt_at": datetime.now(timezone.utc), + }, + ) + + +async def request_operation( + session: AsyncSession, + organization_id: UUID, + user_id: UUID, + *, + request_id: UUID, + kind: str, + product_id: str | None, +) -> BillingOperation: + plan = await organization_plan(session, organization_id, lock=True) + existing = await session.scalar( + select(BillingOperation).where( + BillingOperation.organization_id == organization_id, + BillingOperation.request_id == request_id, + ) + ) + if existing is not None: + if (existing.kind, existing.product_id, existing.created_by) != ( + kind, + product_id, + user_id, + ): + raise ValueError("Request ID already belongs to another billing operation") + return existing + if kind == "checkout": + plan = await claim_billing_source(session, organization_id, "polar") + if plan.subscription_id is not None and plan.tier != "free": + raise ValueError("Manage your existing subscription in the billing portal") + active = await session.scalar( + select(BillingOperation.id).where( + BillingOperation.organization_id == organization_id, + BillingOperation.kind == "checkout", + BillingOperation.status.in_(("pending", "processing", "complete")), + BillingOperation.expires_at > datetime.now(timezone.utc), + ) + ) + if active is not None: + raise ValueError( + "A checkout is already in progress; resume it before starting another" + ) + elif plan.source != "polar" or plan.customer_id is None: + raise ValueError("This workspace does not have a cloud billing account") + operation = BillingOperation( + organization_id=organization_id, + created_by=user_id, + request_id=request_id, + kind=kind, + product_id=product_id, + expires_at=datetime.now(timezone.utc) + OPERATION_LIFETIME, + ) + session.add(operation) + await session.flush() + await append_intent( + session, + organization_id, + event_type=OPERATION_EVENT, + aggregate_id=str(operation.id), + idempotency_key=f"polar:operation:{request_id}", + payload={"operation_id": str(operation.id)}, + ) + return operation + + +async def synchronize_plan( + client: Polar, config: CloudSettings, organization_id: UUID +) -> None: + async with AsyncSessionLocal() as session: + plan = await organization_plan(session, organization_id) + if plan.source != "polar": + return + try: + state = await customer_state(client, str(organization_id)) + except PolarError as exc: + if exc.status_code == 404 and plan.customer_id is None and plan.tier == "free": + return # No customer exists until the first checkout is created. + raise + if ( + state.external_id != str(organization_id) + or state.organization_id != str(config.POLAR_ORGANIZATION_ID) + or plan.customer_id not in {None, state.id} + ): + raise ValueError("Polar customer binding mismatch") + products = { + str(value): key.split(":")[0] for key, value in config.POLAR_PRODUCTS.items() + } + subscription = next(iter(state.subscriptions), None) + review_required = len(state.subscriptions) > 1 or ( + subscription is not None and subscription.product_id not in products + ) + expired_cancellation = bool( + subscription + and subscription.cancel_at_period_end + and subscription.current_period_end is not None + and subscription.current_period_end <= datetime.now(timezone.utc) + ) + entitled = bool( + subscription + and subscription.status in {"active", "trialing"} + and not review_required + and not expired_cancellation + ) + async with AsyncSessionLocal() as session: + applied = await apply_billing_plan( + session, + organization_id, + expected_revision=plan.revision, + source="polar", + status="review_required" + if review_required + else ( + "canceled" + if expired_cancellation + else subscription.status + if subscription + else "free" + ), + tier=products[subscription.product_id] + if entitled and subscription + else "free", + customer_id=state.id, + subscription_id=subscription.id if subscription else None, + product_id=subscription.product_id if subscription else None, + current_period_end=subscription.current_period_end + if subscription + else None, + cancel_at_period_end=subscription.cancel_at_period_end + if subscription + else False, + ) + await session.commit() + if not applied: + # A competing update invalidates this network response. Outbox retries fetch anew. + raise ValueError("Billing revision changed during synchronization") + if review_required: + raise ValueError("Polar subscription configuration requires billing review") + + +async def deliver_operation( + client: Polar, config: CloudSettings, message: OutboxMessage +) -> None: + operation_id = UUID(str(message.payload["operation_id"])) + async with AsyncSessionLocal() as session: + operation = await session.scalar( + select(BillingOperation) + .where( + BillingOperation.id == operation_id, + BillingOperation.organization_id == message.organization_id, + ) + .with_for_update() + ) + if operation is None or operation.status in {"complete", "failed", "expired"}: + return + if operation.expires_at <= datetime.now(timezone.utc): + operation.status = "expired" + await session.commit() + return + if operation.status == "processing": + # Never repeat a checkout creation after an uncertain worker crash. + operation.status = "failed" + operation.error = ( + "The billing request was interrupted. Please start a new request." + ) + await session.commit() + return + email = await billing_owner_email( + session, operation.organization_id, operation.created_by + ) + plan = await organization_plan(session, operation.organization_id) + if email is None or plan.source != "polar": + operation.status = "failed" + operation.error = "Workspace billing permissions changed." + await session.commit() + return + operation.status = "processing" + await session.commit() + try: + if operation.kind == "checkout": + selected_products = { + key: value + for key, value in config.POLAR_PRODUCTS.items() + if str(value) == operation.product_id + } + if not selected_products: + raise ValueError("This subscription option is no longer available") + await validate_catalog( + client, + organization_id=str(config.POLAR_ORGANIZATION_ID), + products=selected_products, + ) + await synchronize_plan(client, config, operation.organization_id) + async with AsyncSessionLocal() as session: + current = await organization_plan(session, operation.organization_id) + if ( + current.source != "polar" + or current.tier != "free" + or current.status == "review_required" + ): + raise ValueError( + "Manage your existing subscription in the billing portal" + ) + assert operation.product_id is not None + url = await checkout_url( + client, + external_id=str(operation.organization_id), + email=email, + product_id=operation.product_id, + success_url=config.return_url + "?checkout=success", + return_url=config.return_url, + request_id=str(operation.id), + ) + else: + url = await portal_url( + client, + external_id=str(operation.organization_id), + return_url=config.return_url, + ) + destination = urlsplit(url) + if ( + destination.scheme != "https" + or not destination.hostname + or destination.username + or destination.password + ): + raise ValueError("Polar returned an invalid billing URL") + except (PolarError, httpx.HTTPError, ValueError): + # Vendor exceptions may contain customer data or bearer URLs; persist no details. + async with AsyncSessionLocal() as session: + await session.execute( + update(BillingOperation) + .where(BillingOperation.id == operation_id) + .values( + status="failed", + error="Billing is temporarily unavailable. Please try again.", + ) + ) + await session.commit() + return + async with AsyncSessionLocal() as session: + await session.execute( + update(BillingOperation) + .where( + BillingOperation.id == operation_id, + BillingOperation.status == "processing", + ) + .values( + status="complete", + result_ciphertext=result_cipher().encrypt(url.encode()).decode(), + ) + ) + await session.commit() + + +async def expire_operations(session: AsyncSession) -> None: + await session.execute( + update(BillingOperation) + .where( + BillingOperation.expires_at <= datetime.now(timezone.utc), + BillingOperation.status != "expired", + ) + .values(status="expired", result_ciphertext=None, error=None) + ) diff --git a/ee/cloud/src/shim_cloud/config.py b/ee/cloud/src/shim_cloud/config.py new file mode 100644 index 0000000..02332df --- /dev/null +++ b/ee/cloud/src/shim_cloud/config.py @@ -0,0 +1,58 @@ +"""Validated cloud-only billing configuration.""" + +from typing import Literal +from urllib.parse import urlsplit +from uuid import UUID + +from pydantic import Field, SecretStr, ValidationInfo, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +ProductKey = Literal[ + "managed:monthly", "managed:yearly", "agency:monthly", "agency:yearly" +] + + +class CloudSettings(BaseSettings): + POLAR_ACCESS_TOKEN: SecretStr = Field(min_length=1) + POLAR_WEBHOOK_SECRET: SecretStr = Field(min_length=1) + POLAR_ORGANIZATION_ID: UUID + POLAR_SERVER: Literal["sandbox", "production"] = "sandbox" + POLAR_PRODUCTS: dict[ProductKey, UUID] = Field(min_length=1) + CLOUD_DASHBOARD_URL: str + CLOUD_BILLING_RECONCILE_SECONDS: int = Field(default=300, ge=30, le=3600) + + model_config = SettingsConfigDict( + env_file="ee/cloud/.env", extra="ignore", hide_input_in_errors=True + ) + + @field_validator("POLAR_PRODUCTS") + @classmethod + def validate_products(cls, value: dict[ProductKey, UUID]) -> dict[ProductKey, UUID]: + if len(set(value.values())) != len(value): + raise ValueError( + "Polar product IDs must identify exactly one plan/interval" + ) + return value + + @field_validator("CLOUD_DASHBOARD_URL") + @classmethod + def validate_dashboard_url(cls, value: str, info: ValidationInfo) -> str: + url = urlsplit(value) + if ( + url.scheme not in {"https", "http"} + or not url.hostname + or url.username + or url.password + or url.path not in {"", "/"} + or url.query + or url.fragment + ): + raise ValueError("CLOUD_DASHBOARD_URL must be an HTTP(S) origin") + if info.data.get("POLAR_SERVER") == "production" and url.scheme != "https": + raise ValueError("production billing requires an HTTPS dashboard") + return value.rstrip("/") + + @property + def return_url(self) -> str: + return f"{self.CLOUD_DASHBOARD_URL}/dashboard/workspace/subscription" diff --git a/ee/cloud/src/shim_cloud/migrate.py b/ee/cloud/src/shim_cloud/migrate.py new file mode 100644 index 0000000..bd68425 --- /dev/null +++ b/ee/cloud/src/shim_cloud/migrate.py @@ -0,0 +1,17 @@ +"""Apply enterprise migrations before the independent cloud schema.""" + +from pathlib import Path +import subprocess +import sys + + +def main() -> None: + for path in (Path("ee/alembic.ini"), Path("ee/cloud/alembic.ini")): + subprocess.run( + [sys.executable, "-m", "alembic", "-c", str(path), "upgrade", "head"], + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/ee/cloud/src/shim_cloud/models.py b/ee/cloud/src/shim_cloud/models.py new file mode 100644 index 0000000..13ee9d4 --- /dev/null +++ b/ee/cloud/src/shim_cloud/models.py @@ -0,0 +1,59 @@ +"""Cloud-owned, short-lived checkout/portal results; entitlements stay in tenants.""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + Index, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from shim_enterprise.tenants.models import Organization, User + + +class CloudBase(DeclarativeBase): + pass + + +class BillingOperation(CloudBase): + __tablename__ = "billing_operation" + __table_args__ = ( + UniqueConstraint( + "organization_id", "request_id", name="uq_billing_operation_request" + ), + CheckConstraint( + "kind IN ('checkout', 'portal')", name="ck_billing_operation_kind" + ), + CheckConstraint( + "status IN ('pending', 'processing', 'complete', 'failed', 'expired')", + name="ck_billing_operation_status", + ), + Index("ix_billing_operation_expiry", "expires_at"), + {"schema": "shim_cloud"}, + ) + + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + organization_id: Mapped[UUID] = mapped_column( + ForeignKey(Organization.id), nullable=False + ) + created_by: Mapped[UUID] = mapped_column(ForeignKey(User.id), nullable=False) + request_id: Mapped[UUID] = mapped_column(nullable=False) + kind: Mapped[str] = mapped_column(Text) + product_id: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column( + Text, default="pending", server_default="pending" + ) + result_ciphertext: Mapped[str | None] = mapped_column(Text) + error: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) diff --git a/ee/cloud/src/shim_cloud/polar.py b/ee/cloud/src/shim_cloud/polar.py new file mode 100644 index 0000000..c780508 --- /dev/null +++ b/ee/cloud/src/shim_cloud/polar.py @@ -0,0 +1,126 @@ +"""Small adapter around the pinned Polar SDK.""" + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from polar_sdk import Polar, models + +from shim_cloud.config import ProductKey + +POLAR_TIMEOUT_MS = 10_000 + +_FIXED_PRICE_TYPES = ( + models.ProductPriceFixed, + models.LegacyRecurringProductPriceFixed, +) + + +@dataclass(frozen=True, slots=True) +class SubscriptionSnapshot: + id: str + product_id: str + status: str + current_period_end: datetime | None + cancel_at_period_end: bool + + +@dataclass(frozen=True, slots=True) +class CustomerSnapshot: + id: str + external_id: str | None + organization_id: str + subscriptions: tuple[SubscriptionSnapshot, ...] + + +async def customer_state(client: Polar, external_id: str) -> CustomerSnapshot: + state = await client.customers.get_state_external_async(external_id=external_id) + return CustomerSnapshot( + id=state.id, + external_id=state.external_id if isinstance(state.external_id, str) else None, + organization_id=state.organization_id, + subscriptions=tuple( + SubscriptionSnapshot( + id=subscription.id, + product_id=subscription.product_id, + status=subscription.status.value, + current_period_end=subscription.current_period_end, + cancel_at_period_end=subscription.cancel_at_period_end, + ) + for subscription in state.active_subscriptions + ), + ) + + +async def checkout_url( + client: Polar, + *, + external_id: str, + email: str, + product_id: str, + success_url: str, + return_url: str, + request_id: str, +) -> str: + checkout = await client.checkouts.create_async( + request={ + "products": [product_id], + "external_customer_id": external_id, + "customer_email": email, + "success_url": success_url, + "return_url": return_url, + "metadata": {"shim_operation_id": request_id}, + }, + ) + return checkout.url + + +async def portal_url(client: Polar, *, external_id: str, return_url: str) -> str: + session = await client.customer_sessions.create_async( + request={ + "external_customer_id": external_id, + "return_url": return_url, + }, + ) + return session.customer_portal_url + + +async def validate_catalog( + client: Polar, *, organization_id: str, products: dict[ProductKey, UUID] +) -> None: + organization = await client.organizations.get_async(id=organization_id) + if organization.id != organization_id: + raise ValueError("Polar organization binding mismatch") + if organization.subscription_settings.allow_multiple_subscriptions: + raise ValueError("Polar organization allows multiple subscriptions") + + for configured_key, configured_id in products.items(): + product_id = str(configured_id) + product = await client.products.get_async(id=product_id) + if product.id != product_id or product.organization_id != organization_id: + raise ValueError("Polar product merchant binding mismatch") + _, separator, configured_interval = configured_key.rpartition(":") + if not separator or configured_interval not in {"monthly", "yearly"}: + raise ValueError("Polar product key must specify monthly or yearly") + expected_interval = { + "monthly": "month", + "yearly": "year", + }[configured_interval] + if ( + product.is_archived + or not product.is_recurring + or product.recurring_interval is None + or product.recurring_interval.value != expected_interval + or product.recurring_interval_count != 1 + or product.meter_interval is not None + or product.meter_interval_count is not None + ): + raise ValueError("Polar product is not a supported recurring plan") + + usable_prices = [price for price in product.prices if not price.is_archived] + if ( + len(usable_prices) != 1 + or not isinstance(usable_prices[0], _FIXED_PRICE_TYPES) + or usable_prices[0].price_currency.lower() != "usd" + ): + raise ValueError("Polar product must have exactly one fixed USD price") diff --git a/ee/cloud/src/shim_cloud/worker.py b/ee/cloud/src/shim_cloud/worker.py new file mode 100644 index 0000000..2c2b565 --- /dev/null +++ b/ee/cloud/src/shim_cloud/worker.py @@ -0,0 +1,101 @@ +"""One enterprise outbox consumer with cloud handlers and periodic sync intents.""" + +import asyncio +from contextlib import suppress +from datetime import datetime, timezone +from functools import partial +import logging + +import httpx +from polar_sdk import Polar, PolarError +from sqlalchemy.exc import SQLAlchemyError + +from shim_enterprise.core.database import AsyncSessionLocal +from shim_enterprise.outbox.handlers import build_publisher +from shim_enterprise.outbox.publisher import OutboxMessage +from shim_enterprise.tenants.plans import billing_organization_ids +from shim_enterprise.workers.outbox import main as run_outbox +from shim_cloud.billing import ( + OPERATION_EVENT, + SYNC_EVENT, + append_intent, + deliver_operation, + expire_operations, + synchronize_plan, +) +from shim_cloud.config import CloudSettings +from shim_cloud.polar import POLAR_TIMEOUT_MS + +logger = logging.getLogger(__name__) + + +async def sync_message( + client: Polar, config: CloudSettings, message: OutboxMessage +) -> None: + try: + await synchronize_plan(client, config, message.organization_id) + except (PolarError, httpx.HTTPError): + # Outbox failure records must not contain vendor response bodies or URLs. + raise ValueError( + "Polar state refresh failed; retaining verified entitlements" + ) from None + + +async def enqueue_reconciliation(config: CloudSettings) -> None: + interval = config.CLOUD_BILLING_RECONCILE_SECONDS + bucket = int(datetime.now(timezone.utc).timestamp()) // interval + after = None + while True: + async with AsyncSessionLocal() as session: + organizations = await billing_organization_ids( + session, "polar", after=after + ) + for organization_id in organizations: + await append_intent( + session, + organization_id, + event_type=SYNC_EVENT, + aggregate_id=str(organization_id), + idempotency_key=f"polar:reconcile:{bucket}", + payload={}, + ) + await session.commit() + if not organizations: + break + after = organizations[-1] + async with AsyncSessionLocal() as session: + await expire_operations(session) + await session.commit() + + +async def reconcile(config: CloudSettings) -> None: + while True: + try: + await enqueue_reconciliation(config) + except SQLAlchemyError: + logger.error("Cloud billing reconciliation enqueue failed") + await asyncio.sleep(config.CLOUD_BILLING_RECONCILE_SECONDS) + + +async def main() -> None: + config = CloudSettings() + async with Polar( + access_token=config.POLAR_ACCESS_TOKEN.get_secret_value(), + server=config.POLAR_SERVER, + retry_config=None, + timeout_ms=POLAR_TIMEOUT_MS, + ) as client: + publisher = build_publisher() + publisher.register(OPERATION_EVENT, partial(deliver_operation, client, config)) + publisher.register(SYNC_EVENT, partial(sync_message, client, config)) + task = asyncio.create_task(reconcile(config)) + try: + await run_outbox(publisher) + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ee/cloud/tests/test_billing.py b/ee/cloud/tests/test_billing.py new file mode 100644 index 0000000..5d4c255 --- /dev/null +++ b/ee/cloud/tests/test_billing.py @@ -0,0 +1,973 @@ +"""Cloud billing routes persist intents before the worker contacts Polar.""" + +import asyncio +import base64 +from collections.abc import AsyncIterator +from datetime import datetime, timedelta, timezone +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient, MockTransport, Request, Response +from polar_sdk import Polar, ResourceNotFound, ResourceNotFoundData, SDKError +from pydantic import ValidationError +import pytest +import pytest_asyncio +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from standardwebhooks import Webhook + +from shim_enterprise.api.enterprise_deps import get_current_user +from shim_enterprise.core.config import settings +from shim_enterprise.core.database import get_db +from shim_enterprise.outbox.models import OutboxEvent +from shim_enterprise.outbox.publisher import OutboxMessage +from shim_enterprise.tenants.models import Organization, User +from shim_enterprise.tenants.plans import activate_organization_plan +from shim_cloud import billing as billing_module +from shim_cloud import worker as worker_module +from shim_cloud.api import operation_view, router +from shim_cloud.billing import OPERATION_EVENT, SYNC_EVENT, request_operation +from shim_cloud.config import CloudSettings +from shim_cloud.models import BillingOperation +from shim_cloud.polar import ( + POLAR_TIMEOUT_MS, + CustomerSnapshot, + SubscriptionSnapshot, +) + +_WEBHOOK_SECRET = "whsec_" + base64.b64encode(b"cloud-billing-test-secret").decode() + + +@pytest_asyncio.fixture +async def session_factory() -> AsyncIterator[async_sessionmaker[AsyncSession]]: + engine = create_async_engine( + settings.DATABASE_URL, + connect_args={"statement_cache_size": 0}, + ) + yield async_sessionmaker(engine, expire_on_commit=False, autoflush=False) + await engine.dispose() + + +def _config() -> CloudSettings: + return CloudSettings( + _env_file=None, + POLAR_ACCESS_TOKEN="test-polar-token", + POLAR_WEBHOOK_SECRET=_WEBHOOK_SECRET, + POLAR_ORGANIZATION_ID=uuid4(), + POLAR_PRODUCTS={ + "managed:monthly": uuid4(), + "agency:yearly": uuid4(), + }, + CLOUD_DASHBOARD_URL="https://cloud.example", + ) + + +async def _workspace( + session_factory: async_sessionmaker[AsyncSession], +) -> tuple[UUID, UUID, UUID, UUID, UUID]: + organization_id = uuid4() + owner_id = uuid4() + admin_id = uuid4() + other_organization_id = uuid4() + other_owner_id = uuid4() + async with session_factory() as session: + session.add_all( + [ + Organization( + id=organization_id, + name="Cloud billing test", + slug=f"cloud-billing-{organization_id}", + ), + User( + id=owner_id, + organization_id=organization_id, + email=f"owner-{owner_id}@example.com", + role="owner", + is_active=True, + is_verified=True, + ), + User( + id=admin_id, + organization_id=organization_id, + email=f"admin-{admin_id}@example.com", + role="admin", + is_active=True, + is_verified=True, + ), + Organization( + id=other_organization_id, + name="Other cloud billing test", + slug=f"other-cloud-billing-{other_organization_id}", + ), + User( + id=other_owner_id, + organization_id=other_organization_id, + email=f"other-owner-{other_owner_id}@example.com", + role="owner", + is_active=True, + is_verified=True, + ), + ] + ) + await session.commit() + return organization_id, owner_id, admin_id, other_organization_id, other_owner_id + + +async def _delete_workspace( + session_factory: async_sessionmaker[AsyncSession], + organization_ids: tuple[UUID, ...], +) -> None: + async with session_factory() as session: + await session.execute( + delete(BillingOperation).where( + BillingOperation.organization_id.in_(organization_ids) + ) + ) + await session.execute( + delete(OutboxEvent).where(OutboxEvent.organization_id.in_(organization_ids)) + ) + await session.execute( + delete(User).where(User.organization_id.in_(organization_ids)) + ) + await session.execute( + delete(Organization).where(Organization.id.in_(organization_ids)) + ) + await session.commit() + + +def _app( + config: CloudSettings, + database: AsyncSession, + current: dict[str, object], +) -> FastAPI: + app = FastAPI() + app.state.cloud_settings = config + app.include_router(router, prefix="/api/v1") + app.dependency_overrides[get_current_user] = lambda: current["user"] + app.dependency_overrides[get_db] = lambda: database + return app + + +def _signed_webhook( + config: CloudSettings, event: dict[str, object], webhook_id: str +) -> tuple[bytes, dict[str, str]]: + body = json.dumps(event, separators=(",", ":"), sort_keys=True).encode() + timestamp = datetime.now(timezone.utc) + signature = Webhook(config.POLAR_WEBHOOK_SECRET.get_secret_value()).sign( + webhook_id, timestamp, body.decode() + ) + return body, { + "webhook-id": webhook_id, + "webhook-signature": signature, + "webhook-timestamp": str(int(timestamp.timestamp())), + } + + +def _message(operation: BillingOperation) -> OutboxMessage: + return OutboxMessage( + id=uuid4(), + organization_id=operation.organization_id, + event_type=OPERATION_EVENT, + aggregate_type="cloud_billing", + aggregate_id=str(operation.id), + idempotency_key=f"test:{operation.id}", + payload={"operation_id": str(operation.id)}, + attempt_count=0, + created_at=datetime.now(timezone.utc), + ) + + +@pytest.mark.asyncio +async def test_owner_checkout_is_durable_idempotent_and_tenant_scoped( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + config = _config() + ids = await _workspace(session_factory) + organization_id, owner_id, admin_id, other_organization_id, other_owner_id = ids + request_id = uuid4() + other_operation_id = uuid4() + try: + async with session_factory() as database: + owner = SimpleNamespace( + id=owner_id, organization_id=organization_id, role="owner" + ) + admin = SimpleNamespace( + id=admin_id, organization_id=organization_id, role="admin" + ) + other_operation = BillingOperation( + id=other_operation_id, + organization_id=other_organization_id, + created_by=other_owner_id, + request_id=uuid4(), + kind="checkout", + product_id=str(config.POLAR_PRODUCTS["managed:monthly"]), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=1), + ) + database.add(other_operation) + await database.commit() + + current = {"user": admin} + app = _app(config, database, current) + payload = { + "request_id": str(request_id), + "plan": "managed", + "interval": "monthly", + } + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + status = await client.get("/api/v1/management/cloud-billing") + assert status.status_code == 200 + assert status.json()["can_manage"] is False + assert status.json()["can_checkout"] is False + assert status.json()["can_open_portal"] is False + assert ( + await client.post( + "/api/v1/management/cloud-billing/checkout", json=payload + ) + ).status_code == 403 + + current["user"] = owner + capabilities = ( + await client.get("/api/v1/management/cloud-billing") + ).json() + assert capabilities["can_checkout"] is True + assert capabilities["can_open_portal"] is False + first = await client.post( + "/api/v1/management/cloud-billing/checkout", json=payload + ) + assert first.status_code == 202, first.text + assert first.headers["cache-control"] == "no-store" + assert first.json()["status"] == "pending" + operation_id = UUID(first.json()["id"]) + + repeated = await client.post( + "/api/v1/management/cloud-billing/checkout", json=payload + ) + assert repeated.status_code == 202 + assert repeated.json()["id"] == str(operation_id) + + changed_intent = await client.post( + "/api/v1/management/cloud-billing/checkout", + json={**payload, "plan": "agency", "interval": "yearly"}, + ) + assert changed_intent.status_code == 409 + cross_organization = await client.get( + f"/api/v1/management/cloud-billing/operations/{other_operation_id}" + ) + assert cross_organization.status_code == 404 + organization = await database.get(Organization, organization_id) + assert organization is not None + organization.tier = "managed" + organization.billing_status = "active" + organization.external_customer_id = str(uuid4()) + await database.commit() + capabilities = ( + await client.get("/api/v1/management/cloud-billing") + ).json() + assert capabilities["can_checkout"] is False + assert capabilities["can_open_portal"] is True + assert capabilities["products"] == [] + + operations = list( + ( + await database.scalars( + select(BillingOperation).where( + BillingOperation.organization_id == organization_id + ) + ) + ).all() + ) + intents = list( + ( + await database.scalars( + select(OutboxEvent).where( + OutboxEvent.organization_id == organization_id, + OutboxEvent.event_type == OPERATION_EVENT, + ) + ) + ).all() + ) + assert [(operation.id, operation.status) for operation in operations] == [ + (operation_id, "pending") + ] + assert [intent.payload for intent in intents] == [ + {"operation_id": str(operation_id)} + ] + finally: + await _delete_workspace( + session_factory, (organization_id, other_organization_id) + ) + + +@pytest.mark.asyncio +async def test_concurrent_checkout_requests_commit_one_operation_and_intent( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + ids = await _workspace(session_factory) + organization_id, owner_id, _, other_organization_id, _ = ids + start = asyncio.Event() + + async def request(request_id: UUID) -> str: + await start.wait() + async with session_factory() as session: + try: + await request_operation( + session, + organization_id, + owner_id, + request_id=request_id, + kind="checkout", + product_id="product-managed", + ) + await session.commit() + except ValueError as exc: + await session.rollback() + return str(exc) + return "created" + + try: + tasks = [asyncio.create_task(request(uuid4())) for _ in range(2)] + start.set() + outcome = await asyncio.gather(*tasks) + assert outcome.count("created") == 1 + assert ( + outcome.count( + "A checkout is already in progress; resume it before starting another" + ) + == 1 + ) + async with session_factory() as session: + operations = list( + ( + await session.scalars( + select(BillingOperation).where( + BillingOperation.organization_id == organization_id + ) + ) + ).all() + ) + intents = list( + ( + await session.scalars( + select(OutboxEvent).where( + OutboxEvent.organization_id == organization_id, + OutboxEvent.event_type == OPERATION_EVENT, + ) + ) + ).all() + ) + assert len(operations) == len(intents) == 1 + finally: + await _delete_workspace( + session_factory, (organization_id, other_organization_id) + ) + + +@pytest.mark.asyncio +async def test_webhook_requires_a_body_bound_signature_and_customer_binding( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + config = _config() + ids = await _workspace(session_factory) + organization_id, owner_id, _, other_organization_id, _ = ids + customer_id = uuid4() + try: + async with session_factory() as database: + organization = await database.get(Organization, organization_id) + owner = await database.get(User, owner_id) + assert organization is not None and owner is not None + organization.billing_source = "polar" + organization.external_customer_id = str(customer_id) + await database.commit() + app = _app(config, database, {"user": owner}) + + event = { + "type": "customer.state_changed", + "data": { + "id": str(customer_id), + "external_id": str(organization_id), + "organization_id": str(config.POLAR_ORGANIZATION_ID), + }, + } + body, headers = _signed_webhook(config, event, "delivery-1") + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + assert ( + await client.post( + "/api/v1/webhooks/polar", content=body, headers=headers + ) + ).status_code == 204 + assert ( + await client.post( + "/api/v1/webhooks/polar", content=body, headers=headers + ) + ).status_code == 204 + + altered_body = body + b" " + assert ( + await client.post( + "/api/v1/webhooks/polar", content=altered_body, headers=headers + ) + ).status_code == 403 + + changed_event = {**event, "delivery_version": 2} + changed_body, changed_headers = _signed_webhook( + config, changed_event, "delivery-1" + ) + assert ( + await client.post( + "/api/v1/webhooks/polar", + content=changed_body, + headers=changed_headers, + ) + ).status_code == 409 + + merchant_event = { + **event, + "data": {**event["data"], "organization_id": str(uuid4())}, + } + merchant_body, merchant_headers = _signed_webhook( + config, merchant_event, "merchant-mismatch" + ) + assert ( + await client.post( + "/api/v1/webhooks/polar", + content=merchant_body, + headers=merchant_headers, + ) + ).status_code == 403 + + customer_event = { + **event, + "data": {**event["data"], "id": str(uuid4())}, + } + customer_body, customer_headers = _signed_webhook( + config, customer_event, "customer-mismatch" + ) + assert ( + await client.post( + "/api/v1/webhooks/polar", + content=customer_body, + headers=customer_headers, + ) + ).status_code == 403 + + intents = list( + ( + await database.scalars( + select(OutboxEvent).where( + OutboxEvent.organization_id == organization_id, + OutboxEvent.event_type == SYNC_EVENT, + ) + ) + ).all() + ) + assert len(intents) == 1 + assert intents[0].idempotency_key == "polar:webhook:delivery-1" + finally: + await _delete_workspace( + session_factory, (organization_id, other_organization_id) + ) + + +@pytest.mark.asyncio +async def test_subscription_sync_enforces_review_expiry_and_operator_revision( + session_factory: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +) -> None: + config = _config() + monkeypatch.setattr(billing_module, "AsyncSessionLocal", session_factory) + ids = await _workspace(session_factory) + organization_id, _, _, other_organization_id, _ = ids + customer_id = "customer-1" + known_product = str(config.POLAR_PRODUCTS["managed:monthly"]) + + def state(*subscriptions: SubscriptionSnapshot) -> CustomerSnapshot: + return CustomerSnapshot( + id=customer_id, + external_id=str(organization_id), + organization_id=str(config.POLAR_ORGANIZATION_ID), + subscriptions=subscriptions, + ) + + async def synchronize(snapshot: CustomerSnapshot) -> None: + async def fetch(_client: object, _external_id: str) -> CustomerSnapshot: + return snapshot + + monkeypatch.setattr(billing_module, "customer_state", fetch) + await billing_module.synchronize_plan(object(), config, organization_id) + + try: + async with session_factory() as session: + organization = await session.get(Organization, organization_id) + assert organization is not None + organization.billing_source = "polar" + organization.billing_revision = 1 + await session.commit() + + await synchronize( + state( + SubscriptionSnapshot( + id="subscription-1", + product_id=known_product, + status="active", + current_period_end=datetime.now(timezone.utc) + timedelta(days=30), + cancel_at_period_end=False, + ) + ) + ) + async with session_factory() as session: + current = await session.get(Organization, organization_id) + assert current is not None + assert (current.tier, current.billing_status, current.billing_source) == ( + "managed", + "active", + "polar", + ) + + with pytest.raises(ValueError, match="billing review"): + await synchronize( + state( + SubscriptionSnapshot( + id="subscription-unknown", + product_id="unmapped-product", + status="active", + current_period_end=datetime.now(timezone.utc) + + timedelta(days=30), + cancel_at_period_end=False, + ) + ) + ) + async with session_factory() as session: + current = await session.get(Organization, organization_id) + assert current is not None + assert (current.tier, current.billing_status) == ("free", "review_required") + + with pytest.raises(ValueError, match="billing review"): + await synchronize( + state( + SubscriptionSnapshot( + id="subscription-first", + product_id=known_product, + status="active", + current_period_end=datetime.now(timezone.utc) + + timedelta(days=30), + cancel_at_period_end=False, + ), + SubscriptionSnapshot( + id="subscription-second", + product_id=known_product, + status="active", + current_period_end=datetime.now(timezone.utc) + + timedelta(days=30), + cancel_at_period_end=False, + ), + ) + ) + + await synchronize( + state( + SubscriptionSnapshot( + id="subscription-canceled", + product_id=known_product, + status="active", + current_period_end=datetime.now(timezone.utc) + - timedelta(seconds=1), + cancel_at_period_end=True, + ) + ) + ) + async with session_factory() as session: + current = await session.get(Organization, organization_id) + assert current is not None + assert ( + current.tier, + current.billing_status, + current.cancel_at_period_end, + ) == ( + "free", + "canceled", + True, + ) + + fetched = asyncio.Event() + continue_sync = asyncio.Event() + active_state = state( + SubscriptionSnapshot( + id="subscription-2", + product_id=known_product, + status="active", + current_period_end=datetime.now(timezone.utc) + timedelta(days=30), + cancel_at_period_end=False, + ) + ) + + async def delayed_fetch(_client: object, _external_id: str) -> CustomerSnapshot: + fetched.set() + await continue_sync.wait() + return active_state + + monkeypatch.setattr(billing_module, "customer_state", delayed_fetch) + sync_task = asyncio.create_task( + billing_module.synchronize_plan(object(), config, organization_id) + ) + await fetched.wait() + async with session_factory() as session: + await activate_organization_plan(session, organization_id, "agency") + await session.commit() + continue_sync.set() + with pytest.raises(ValueError, match="Billing revision changed"): + await sync_task + async with session_factory() as session: + current = await session.get(Organization, organization_id) + assert current is not None + assert (current.tier, current.billing_source) == ("agency", "operator") + finally: + await _delete_workspace( + session_factory, (organization_id, other_organization_id) + ) + + +@pytest.mark.asyncio +async def test_first_checkout_treats_polar_customer_404_as_no_customer( + session_factory: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config() + monkeypatch.setattr(billing_module, "AsyncSessionLocal", session_factory) + ids = await _workspace(session_factory) + organization_id, owner_id, _, other_organization_id, _ = ids + requests: list[Request] = [] + + async def handler(request: Request) -> Response: + requests.append(request) + return Response( + 404, + json={"detail": "customer not found", "error": "ResourceNotFound"}, + request=request, + ) + + http_client = AsyncClient(transport=MockTransport(handler)) + client = Polar( + access_token="test-polar-token", + async_client=http_client, + retry_config=None, + timeout_ms=POLAR_TIMEOUT_MS, + ) + validate_catalog = AsyncMock() + create_checkout = AsyncMock(return_value="https://checkout.example/session") + monkeypatch.setattr(billing_module, "validate_catalog", validate_catalog) + monkeypatch.setattr(billing_module, "checkout_url", create_checkout) + try: + async with session_factory() as session: + organization = await session.get(Organization, organization_id) + assert organization is not None + organization.billing_source = "polar" + operation = BillingOperation( + organization_id=organization_id, + created_by=owner_id, + request_id=uuid4(), + kind="checkout", + product_id=str(config.POLAR_PRODUCTS["managed:monthly"]), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=10), + ) + session.add(operation) + await session.commit() + + await billing_module.deliver_operation(client, config, _message(operation)) + + validate_catalog.assert_awaited_once() + create_checkout.assert_awaited_once() + assert [request.url.path for request in requests] == [ + f"/v1/customers/external/{organization_id}/state" + ] + async with session_factory() as session: + completed = await session.get(BillingOperation, operation.id) + assert completed is not None + assert completed.status == "complete" + assert completed.error is None + finally: + await http_client.aclose() + await _delete_workspace( + session_factory, (organization_id, other_organization_id) + ) + + +@pytest.mark.asyncio +async def test_worker_hides_vendor_error_bodies_on_sync_outage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config() + secret = "polar-response-bearer-secret" + failure = SDKError("Polar failure", Response(503, text=secret)) + + async def unavailable( + _client: object, _config: CloudSettings, _organization: UUID + ) -> None: + raise failure + + monkeypatch.setattr(worker_module, "synchronize_plan", unavailable) + message = OutboxMessage( + id=uuid4(), + organization_id=uuid4(), + event_type=SYNC_EVENT, + aggregate_type="cloud_billing", + aggregate_id="test", + idempotency_key="test", + payload={}, + attempt_count=0, + created_at=datetime.now(timezone.utc), + ) + with pytest.raises(ValueError, match="Polar state refresh failed") as error: + await worker_module.sync_message(SimpleNamespace(), config, message) + assert secret not in str(error.value) + + +@pytest.mark.asyncio +async def test_worker_hides_typed_polar_error_bodies_on_sync_outage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config() + secret = "polar-resource-detail-secret" + failure = ResourceNotFound( + ResourceNotFoundData(detail=secret), Response(404, text=secret) + ) + + async def unavailable( + _client: object, _config: CloudSettings, _organization: UUID + ) -> None: + raise failure + + monkeypatch.setattr(worker_module, "synchronize_plan", unavailable) + message = OutboxMessage( + id=uuid4(), + organization_id=uuid4(), + event_type=SYNC_EVENT, + aggregate_type="cloud_billing", + aggregate_id="test", + idempotency_key="test", + payload={}, + attempt_count=0, + created_at=datetime.now(timezone.utc), + ) + with pytest.raises(ValueError, match="Polar state refresh failed") as error: + await worker_module.sync_message(SimpleNamespace(), config, message) + assert secret not in str(error.value) + + +@pytest.mark.asyncio +async def test_worker_never_repeats_crashed_checkout_and_hides_expired_urls( + session_factory: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +) -> None: + config = _config() + monkeypatch.setattr(billing_module, "AsyncSessionLocal", session_factory) + ids = await _workspace(session_factory) + organization_id, owner_id, _, other_organization_id, _ = ids + checkout_url = "https://checkout.example/session-secret" + try: + async with session_factory() as session: + organization = await session.get(Organization, organization_id) + assert organization is not None + organization.billing_source = "polar" + crashed = BillingOperation( + organization_id=organization_id, + created_by=owner_id, + request_id=uuid4(), + kind="checkout", + product_id=str(config.POLAR_PRODUCTS["managed:monthly"]), + status="processing", + expires_at=datetime.now(timezone.utc) + timedelta(minutes=1), + ) + pending = BillingOperation( + organization_id=organization_id, + created_by=owner_id, + request_id=uuid4(), + kind="checkout", + product_id=str(config.POLAR_PRODUCTS["managed:monthly"]), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=1), + ) + session.add_all([crashed, pending]) + await session.commit() + + validate_catalog = AsyncMock() + synchronize_plan = AsyncMock() + create_checkout = AsyncMock(return_value=checkout_url) + monkeypatch.setattr(billing_module, "validate_catalog", validate_catalog) + monkeypatch.setattr(billing_module, "synchronize_plan", synchronize_plan) + monkeypatch.setattr(billing_module, "checkout_url", create_checkout) + + await billing_module.deliver_operation( + SimpleNamespace(), config, _message(crashed) + ) + validate_catalog.assert_not_awaited() + synchronize_plan.assert_not_awaited() + create_checkout.assert_not_awaited() + + await billing_module.deliver_operation( + SimpleNamespace(), config, _message(pending) + ) + validate_catalog.assert_awaited_once() + synchronize_plan.assert_awaited_once() + create_checkout.assert_awaited_once() + + async with session_factory() as session: + crashed_result = await session.get(BillingOperation, crashed.id) + completed = await session.get(BillingOperation, pending.id) + assert crashed_result is not None and completed is not None + assert (crashed_result.status, crashed_result.error) == ( + "failed", + "The billing request was interrupted. Please start a new request.", + ) + assert completed.status == "complete" + assert completed.result_ciphertext is not None + assert checkout_url not in completed.result_ciphertext + assert operation_view(completed).url == checkout_url + completed.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + await session.commit() + assert operation_view(completed).model_dump() == { + "id": completed.id, + "status": "expired", + "url": None, + "error": None, + } + finally: + await _delete_workspace( + session_factory, (organization_id, other_organization_id) + ) + + +@pytest.mark.asyncio +async def test_reconciliation_deduplicates_polar_intents_and_expires_results( + session_factory: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +) -> None: + config = _config() + frozen = datetime(2026, 9, 11, 12, tzinfo=timezone.utc) + ids = await _workspace(session_factory) + organization_id, owner_id, _, other_organization_id, _ = ids + expired_operation_id = uuid4() + + class FrozenDateTime: + @staticmethod + def now(_timezone: timezone) -> datetime: + return frozen + + synchronize_plan = AsyncMock() + monkeypatch.setattr(worker_module, "AsyncSessionLocal", session_factory) + monkeypatch.setattr(worker_module, "datetime", FrozenDateTime) + monkeypatch.setattr(billing_module, "datetime", FrozenDateTime) + monkeypatch.setattr(worker_module, "synchronize_plan", synchronize_plan) + try: + async with session_factory() as session: + organization = await session.get(Organization, organization_id) + operator_organization = await session.get( + Organization, other_organization_id + ) + assert organization is not None and operator_organization is not None + organization.billing_source = "polar" + operator_organization.billing_source = "operator" + session.add( + BillingOperation( + id=expired_operation_id, + organization_id=organization_id, + created_by=owner_id, + request_id=uuid4(), + kind="checkout", + product_id=str(config.POLAR_PRODUCTS["managed:monthly"]), + status="complete", + result_ciphertext=billing_module.result_cipher() + .encrypt(b"https://checkout.example/expired") + .decode(), + error="stale error", + expires_at=frozen - timedelta(seconds=1), + ) + ) + await session.commit() + + await worker_module.enqueue_reconciliation(config) + await worker_module.enqueue_reconciliation(config) + + synchronize_plan.assert_not_awaited() + async with session_factory() as session: + intents = list( + ( + await session.scalars( + select(OutboxEvent).where( + OutboxEvent.event_type == SYNC_EVENT, + OutboxEvent.organization_id.in_( + (organization_id, other_organization_id) + ), + ) + ) + ).all() + ) + expired = await session.get(BillingOperation, expired_operation_id) + assert [ + (intent.organization_id, intent.idempotency_key) for intent in intents + ] == [ + ( + organization_id, + f"polar:reconcile:{int(frozen.timestamp()) // config.CLOUD_BILLING_RECONCILE_SECONDS}", + ) + ] + assert expired is not None + assert (expired.status, expired.result_ciphertext, expired.error) == ( + "expired", + None, + None, + ) + finally: + await _delete_workspace( + session_factory, (organization_id, other_organization_id) + ) + + +def test_invalid_configuration_does_not_print_credentials() -> None: + values = _config().model_dump() + values.update(POLAR_ACCESS_TOKEN="never-log-this", CLOUD_DASHBOARD_URL="ftp://bad") + with pytest.raises(ValidationError) as error: + CloudSettings(_env_file=None, **values) + assert "never-log-this" not in str(error.value) + assert "never-log-this" not in repr(error.value.errors()) + + +def test_configuration_requires_at_least_one_sellable_product( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("POLAR_PRODUCTS", raising=False) + values = _config().model_dump() + values.update(POLAR_PRODUCTS={}) + with pytest.raises(ValidationError): + CloudSettings(_env_file=None, **values) + + +def test_configuration_rejects_duplicate_product_ids() -> None: + values = _config().model_dump() + shared = uuid4() + values["POLAR_PRODUCTS"] = { + "managed:monthly": shared, + "agency:monthly": shared, + } + with pytest.raises(ValidationError): + CloudSettings(_env_file=None, **values) + + +@pytest.mark.parametrize( + "dashboard_url", + ["ftp://bad.test", "https://bad.test/?q=1", "https://user:pass@bad.test"], +) +def test_configuration_rejects_non_origin_dashboard_urls(dashboard_url: str) -> None: + values = _config().model_dump() + values["CLOUD_DASHBOARD_URL"] = dashboard_url + with pytest.raises(ValidationError): + CloudSettings(_env_file=None, **values) + + +def test_production_billing_requires_an_https_dashboard() -> None: + values = _config().model_dump() + values.update(POLAR_SERVER="production", CLOUD_DASHBOARD_URL="http://bad.test") + with pytest.raises(ValidationError): + CloudSettings(_env_file=None, **values) diff --git a/ee/cloud/tests/test_polar.py b/ee/cloud/tests/test_polar.py new file mode 100644 index 0000000..d47b7cd --- /dev/null +++ b/ee/cloud/tests/test_polar.py @@ -0,0 +1,601 @@ +import json +from collections.abc import Awaitable, Callable +from uuid import UUID + +import httpx +import pytest +from polar_sdk import Polar, SDKError + +from shim_cloud.polar import ( + POLAR_TIMEOUT_MS, + checkout_url, + customer_state, + portal_url, + validate_catalog, +) + +Handler = Callable[[httpx.Request], httpx.Response | Awaitable[httpx.Response]] +MONTHLY_PRODUCT_ID = "00000000-0000-0000-0000-000000000001" +YEARLY_PRODUCT_ID = "00000000-0000-0000-0000-000000000002" + + +def _subscription(subscription_id: str, product_id: str) -> dict[str, object]: + return { + "id": subscription_id, + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "metadata": {}, + "status": "active", + "amount": 1000, + "currency": "usd", + "recurring_interval": "month", + "current_period_start": "2026-09-01T00:00:00Z", + "current_period_end": "2026-10-01T00:00:00Z", + "trial_start": None, + "trial_end": None, + "cancel_at_period_end": False, + "canceled_at": None, + "started_at": "2026-09-01T00:00:00Z", + "ends_at": None, + "product_id": product_id, + "discount_id": None, + "meters": [], + } + + +def _customer_state(subscriptions: list[dict[str, object]]) -> dict[str, object]: + return { + "id": "customer-1", + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "metadata": {}, + "email_verified": True, + "name": "Workspace", + "billing_name": None, + "billing_address": None, + "tax_id": None, + "organization_id": "organization-1", + "deleted_at": None, + "avatar_url": None, + "active_subscriptions": subscriptions, + "granted_benefits": [], + "active_meters": [], + "email": "owner@example.com", + "type": "team", + } + + +async def _client(handler: Handler) -> tuple[Polar, httpx.AsyncClient]: + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return ( + Polar( + access_token="test-token", + async_client=http_client, + retry_config=None, + timeout_ms=POLAR_TIMEOUT_MS, + ), + http_client, + ) + + +def _organization(*, allow_multiple_subscriptions: bool = False) -> dict[str, object]: + return { + "id": "organization-1", + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "name": "Shim", + "slug": "shim", + "avatar_url": None, + "proration_behavior": "prorate", + "allow_customer_updates": True, + "email": "owner@example.com", + "website": "https://example.com", + "socials": [], + "status": "active", + "details_submitted_at": "2026-09-01T00:00:00Z", + "sso_enforced": False, + "default_presentment_currency": "usd", + "default_tax_behavior": "location", + "feature_settings": None, + "subscription_settings": { + "allow_multiple_subscriptions": allow_multiple_subscriptions, + "proration_behavior": "prorate", + "benefit_revocation_grace_period": 0, + "prevent_trial_abuse": False, + "allow_customer_updates": True, + }, + "customer_email_settings": { + key: False + for key in ( + "order_confirmation", + "subscription_cancellation", + "subscription_confirmation", + "subscription_cycled", + "subscription_cycled_after_trial", + "subscription_past_due", + "subscription_paused", + "subscription_resumed", + "subscription_renewal_reminder", + "subscription_revoked", + "subscription_trial_conversion_reminder", + "subscription_uncanceled", + "subscription_updated", + ) + }, + "customer_portal_settings": { + "usage": {"show": True}, + "subscription": { + "update_seats": False, + "update_plan": False, + "pause": False, + }, + "customer": {"allow_email_change": False}, + }, + "account_id": "account-1", + "payout_account_id": "payout-1", + "capabilities": { + key: True + for key in ( + "checkout_payments", + "subscription_renewals", + "payouts", + "refunds", + "api_access", + "dashboard_access", + ) + }, + "country": "US", + } + + +def _price( + product_id: str, + *, + amount_type: str = "fixed", + currency: str = "usd", + archived: bool = False, + price_id: str = "price-1", +) -> dict[str, object]: + price: dict[str, object] = { + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "id": price_id, + "source": "catalog", + "price_currency": currency, + "tax_behavior": None, + "is_archived": archived, + "product_id": product_id, + "amount_type": amount_type, + } + if amount_type == "fixed": + price["price_amount"] = 1000 + elif amount_type == "custom": + price.update( + {"minimum_amount": 1000, "maximum_amount": 10000, "preset_amount": 1000} + ) + elif amount_type == "metered_unit": + price.update( + { + "unit_amount": "100", + "cap_amount": None, + "meter_id": "meter-1", + "meter": { + "id": "meter-1", + "name": "API calls", + "unit": "scalar", + "custom_label": None, + "custom_multiplier": None, + }, + } + ) + return price + + +def _product( + product_id: str = "product-1", + *, + organization_id: str = "organization-1", + interval: str = "month", + interval_count: int | None = 1, + is_recurring: bool = True, + is_archived: bool = False, + meter_interval: str | None = None, + meter_interval_count: int | None = None, + prices: list[dict[str, object]] | None = None, +) -> dict[str, object]: + return { + "id": product_id, + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "trial_interval": None, + "trial_interval_count": None, + "name": "Pro", + "description": None, + "visibility": "public", + "recurring_interval": interval, + "recurring_interval_count": interval_count, + "meter_interval": meter_interval, + "meter_interval_count": meter_interval_count, + "is_recurring": is_recurring, + "is_archived": is_archived, + "organization_id": organization_id, + "metadata": {}, + "prices": prices if prices is not None else [_price(product_id)], + "benefits": [], + "medias": [], + "attached_custom_fields": [], + } + + +async def _catalog_client( + organization: dict[str, object], products: dict[str, dict[str, object]] +) -> tuple[Polar, httpx.AsyncClient, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/v1/organizations/organization-1": + return httpx.Response(200, json=organization, request=request) + product_id = request.url.path.rsplit("/", 1)[-1] + return httpx.Response(200, json=products[product_id], request=request) + + client, http_client = await _client(handler) + return client, http_client, requests + + +@pytest.mark.asyncio +async def test_customer_state_maps_empty_and_multiple_subscriptions() -> None: + requests: list[httpx.Request] = [] + payloads = ( + _customer_state([]), + _customer_state( + [ + _subscription("subscription-1", "product-monthly"), + _subscription("subscription-2", "product-yearly"), + ] + ), + ) + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=payloads[len(requests) - 1], request=request) + + client, http_client = await _client(handler) + try: + empty = await customer_state(client, "external-customer") + multiple = await customer_state(client, "external-customer") + finally: + await http_client.aclose() + + assert empty.external_id is None + assert empty.subscriptions == () + assert multiple.organization_id == "organization-1" + assert [ + (item.id, item.product_id, item.status) for item in multiple.subscriptions + ] == [ + ("subscription-1", "product-monthly", "active"), + ("subscription-2", "product-yearly", "active"), + ] + assert [request.url.path for request in requests] == [ + "/v1/customers/external/external-customer/state", + "/v1/customers/external/external-customer/state", + ] + + +@pytest.mark.asyncio +async def test_checkout_url_uses_server_product_and_operation_metadata() -> None: + requests: list[httpx.Request] = [] + response = { + "id": "checkout-1", + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "payment_processor": "stripe", + "status": "open", + "client_secret": "secret", + "url": "https://checkout.polar.sh/checkout-1", + "expires_at": "2026-09-01T01:00:00Z", + "success_url": "https://app.example/success", + "return_url": "https://app.example/return", + "embed_origin": None, + "amount": 1000, + "discount_amount": 0, + "net_amount": 1000, + "tax_amount": 0, + "tax_behavior": None, + "total_amount": 1000, + "currency": "usd", + "allow_trial": False, + "active_trial_interval": None, + "active_trial_interval_count": None, + "trial_end": None, + "organization_id": "polar-organization", + "product_id": "product-monthly", + "product_price_id": None, + "discount_id": None, + "allow_discount_codes": False, + "require_billing_address": False, + "is_discount_applicable": False, + "is_free_product_price": False, + "is_payment_required": True, + "is_payment_setup_required": False, + "is_payment_form_required": True, + "customer_id": "customer-1", + "is_business_customer": False, + "customer_name": None, + "customer_email": "owner@example.com", + "customer_ip_address": None, + "customer_billing_name": None, + "customer_billing_address": None, + "customer_tax_id": None, + "payment_processor_metadata": {}, + "billing_address_fields": { + "country": "disabled", + "state": "disabled", + "city": "disabled", + "postal_code": "disabled", + "line1": "disabled", + "line2": "disabled", + }, + "trial_interval": None, + "trial_interval_count": None, + "metadata": {"shim_operation_id": "operation-1"}, + "external_customer_id": "external-customer", + "products": [], + "product": None, + "product_price": None, + "prices": None, + "discount": None, + "subscription_id": None, + "attached_custom_fields": None, + "customer_metadata": {}, + "custom_field_data": None, + } + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=response, request=request) + + client, http_client = await _client(handler) + try: + url = await checkout_url( + client, + external_id="external-customer", + email="owner@example.com", + product_id="product-monthly", + success_url="https://app.example/success", + return_url="https://app.example/return", + request_id="operation-1", + ) + finally: + await http_client.aclose() + + assert url == "https://checkout.polar.sh/checkout-1" + assert requests[0].method == "POST" + assert requests[0].url.path == "/v1/checkouts/" + assert json.loads(requests[0].content) == { + "allow_discount_codes": True, + "allow_trial": True, + "is_business_customer": False, + "require_billing_address": False, + "products": ["product-monthly"], + "external_customer_id": "external-customer", + "customer_email": "owner@example.com", + "success_url": "https://app.example/success", + "return_url": "https://app.example/return", + "metadata": {"shim_operation_id": "operation-1"}, + } + + +@pytest.mark.asyncio +async def test_portal_url_uses_external_customer_and_return_url() -> None: + requests: list[httpx.Request] = [] + response = { + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "id": "session-1", + "token": "session-token", + "expires_at": "2026-09-01T01:00:00Z", + "return_url": "https://app.example/subscription", + "customer_portal_url": "https://polar.sh/portal/session-1", + "customer_id": "customer-1", + "customer": { + "id": "customer-1", + "created_at": "2026-09-01T00:00:00Z", + "modified_at": None, + "metadata": {}, + "email_verified": True, + "name": "Workspace", + "billing_name": None, + "billing_address": None, + "tax_id": None, + "organization_id": "organization-1", + "deleted_at": None, + "avatar_url": None, + "external_id": "external-customer", + "email": "owner@example.com", + "type": "team", + }, + } + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=response, request=request) + + client, http_client = await _client(handler) + try: + url = await portal_url( + client, + external_id="external-customer", + return_url="https://app.example/subscription", + ) + finally: + await http_client.aclose() + + assert url == "https://polar.sh/portal/session-1" + assert requests[0].method == "POST" + assert requests[0].url.path == "/v1/customer-sessions/" + assert json.loads(requests[0].content) == { + "external_customer_id": "external-customer", + "return_url": "https://app.example/subscription", + } + + +@pytest.mark.asyncio +async def test_customer_state_propagates_non_2xx_without_retry() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(503, json={"detail": "unavailable"}, request=request) + + client, http_client = await _client(handler) + try: + with pytest.raises(SDKError): + await customer_state(client, "external-customer") + finally: + await http_client.aclose() + + assert len(requests) == 1 + + +@pytest.mark.asyncio +async def test_validate_catalog_accepts_supported_products() -> None: + products = { + MONTHLY_PRODUCT_ID: _product(MONTHLY_PRODUCT_ID, interval="month"), + YEARLY_PRODUCT_ID: _product(YEARLY_PRODUCT_ID, interval="year"), + } + client, http_client, requests = await _catalog_client(_organization(), products) + try: + await validate_catalog( + client, + organization_id="organization-1", + products={ + "managed:monthly": UUID(MONTHLY_PRODUCT_ID), + "managed:yearly": UUID(YEARLY_PRODUCT_ID), + }, + ) + finally: + await http_client.aclose() + + assert [request.url.path for request in requests] == [ + "/v1/organizations/organization-1", + "/v1/products/00000000-0000-0000-0000-000000000001", + "/v1/products/00000000-0000-0000-0000-000000000002", + ] + + +@pytest.mark.asyncio +async def test_validate_catalog_rejects_swapped_configured_intervals() -> None: + products = { + MONTHLY_PRODUCT_ID: _product(MONTHLY_PRODUCT_ID, interval="year"), + YEARLY_PRODUCT_ID: _product(YEARLY_PRODUCT_ID, interval="month"), + } + client, http_client, _ = await _catalog_client(_organization(), products) + try: + with pytest.raises(ValueError, match="supported recurring plan"): + await validate_catalog( + client, + organization_id="organization-1", + products={ + "managed:monthly": UUID(MONTHLY_PRODUCT_ID), + "managed:yearly": UUID(YEARLY_PRODUCT_ID), + }, + ) + finally: + await http_client.aclose() + + +@pytest.mark.asyncio +async def test_validate_catalog_rejects_multiple_subscriptions_setting() -> None: + client, http_client, _ = await _catalog_client( + _organization(allow_multiple_subscriptions=True), {} + ) + try: + with pytest.raises(ValueError, match="multiple subscriptions"): + await validate_catalog( + client, + organization_id="organization-1", + products={}, + ) + finally: + await http_client.aclose() + + +@pytest.mark.parametrize( + ("product", "message"), + [ + (_product(organization_id="other-organization"), "merchant binding"), + (_product(is_archived=True), "supported recurring plan"), + (_product(is_recurring=False), "supported recurring plan"), + (_product(interval="week"), "supported recurring plan"), + (_product(interval_count=2), "supported recurring plan"), + ( + _product(meter_interval="month", meter_interval_count=1), + "supported recurring plan", + ), + ( + _product(prices=[_price("product-1", amount_type="custom")]), + "fixed USD price", + ), + ( + _product(prices=[_price("product-1", amount_type="metered_unit")]), + "fixed USD price", + ), + ( + _product( + prices=[ + _price("product-1"), + _price("product-1", price_id="price-2"), + ] + ), + "fixed USD price", + ), + ( + _product(prices=[_price("product-1", currency="eur")]), + "fixed USD price", + ), + ], +) +@pytest.mark.asyncio +async def test_validate_catalog_rejects_unsupported_product_catalog( + product: dict[str, object], message: str +) -> None: + product["id"] = MONTHLY_PRODUCT_ID + product["organization_id"] = product.get("organization_id", "organization-1") + for price in product["prices"]: + assert isinstance(price, dict) + price["product_id"] = MONTHLY_PRODUCT_ID + client, http_client, _ = await _catalog_client( + _organization(), {MONTHLY_PRODUCT_ID: product} + ) + try: + with pytest.raises(ValueError, match=message): + await validate_catalog( + client, + organization_id="organization-1", + products={"managed:monthly": UUID(MONTHLY_PRODUCT_ID)}, + ) + finally: + await http_client.aclose() + + +@pytest.mark.asyncio +async def test_validate_catalog_does_not_retry_sdk_errors() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(503, json={"detail": "unavailable"}, request=request) + + client, http_client = await _client(handler) + try: + with pytest.raises(SDKError): + await validate_catalog( + client, + organization_id="organization-1", + products={}, + ) + finally: + await http_client.aclose() + + assert len(requests) == 1 diff --git a/ee/docs/PROVISIONING.md b/ee/docs/PROVISIONING.md index 45c07a5..127adc4 100644 --- a/ee/docs/PROVISIONING.md +++ b/ee/docs/PROVISIONING.md @@ -81,3 +81,12 @@ These regression checks cover new provisioning, invalid inputs, transitions across all existing tiers, active/revoked/new keys, tenant separation, historical billing records, ledger values, and quota counters. They use disposable PostgreSQL and make no subscription-service calls. + +## Hosted cloud composition + +The hosted service adds [Polar commerce](../cloud/README.md) through a separate +package/image. The on-prem application and provisioning commands do not install +or import it. Shared plan updates preserve usage and increment a billing revision; +an operator override takes authority from cloud sync. Optional organization-wide +quotas are maintained on tier changes only for tenants already opted into them. +The existing read-only subscription endpoint is unchanged. diff --git a/ee/src/shim_enterprise/api/v1/management.py b/ee/src/shim_enterprise/api/v1/management.py index dd57b48..dc8cd2d 100644 --- a/ee/src/shim_enterprise/api/v1/management.py +++ b/ee/src/shim_enterprise/api/v1/management.py @@ -193,6 +193,25 @@ class MembershipView(MembershipInput): source: Literal["local", "oidc"] +def _validate_allowed_models(value: list[str] | None) -> list[str] | None: + if value is not None and any( + not item or item != item.strip() or len(item) > 200 for item in value + ): + raise ValueError( + "Model identifiers must be nonblank and at most 200 characters" + ) + return list(dict.fromkeys(value)) if value is not None else None + + +def _validate_attribution(value: str | None) -> str | None: + if value is None: + return None + return normalize_attribution( + value, + maximum_length=settings.COST_TAG_MAX_LENGTH, + ) + + class ApiKeyInput(BaseModel): name: str = Field(min_length=1, max_length=50) cost_center: str | None = None @@ -200,26 +219,8 @@ class ApiKeyInput(BaseModel): team_id: UUID | None = None allowed_models: list[str] | None = Field(default=None, max_length=200) - @field_validator("allowed_models") - @classmethod - def validate_models(cls, value: list[str] | None) -> list[str] | None: - if value is not None and any( - not item or item != item.strip() or len(item) > 200 for item in value - ): - raise ValueError( - "Model identifiers must be nonblank and at most 200 characters" - ) - return list(dict.fromkeys(value)) if value is not None else None - - @field_validator("cost_center", "team") - @classmethod - def validate_attribution(cls, value: str | None) -> str | None: - if value is None: - return None - return normalize_attribution( - value, - maximum_length=settings.COST_TAG_MAX_LENGTH, - ) + validate_models = field_validator("allowed_models")(_validate_allowed_models) + validate_attribution = field_validator("cost_center", "team")(_validate_attribution) class ApiKeyPatch(BaseModel): @@ -228,26 +229,8 @@ class ApiKeyPatch(BaseModel): team_id: UUID | None = None allowed_models: list[str] | None = Field(default=None, max_length=200) - @field_validator("allowed_models") - @classmethod - def validate_models(cls, value: list[str] | None) -> list[str] | None: - if value is not None and any( - not item or item != item.strip() or len(item) > 200 for item in value - ): - raise ValueError( - "Model identifiers must be nonblank and at most 200 characters" - ) - return list(dict.fromkeys(value)) if value is not None else None - - @field_validator("cost_center", "team") - @classmethod - def validate_attribution(cls, value: str | None) -> str | None: - if value is None: - return None - return normalize_attribution( - value, - maximum_length=settings.COST_TAG_MAX_LENGTH, - ) + validate_models = field_validator("allowed_models")(_validate_allowed_models) + validate_attribution = field_validator("cost_center", "team")(_validate_attribution) class ApiKeyView(BaseModel): @@ -1042,7 +1025,7 @@ async def update_team( user: User = Depends(get_org_admin), session: AsyncSession = Depends(get_db), ) -> Team: - await require_team(session, user, team_id, administer=True) + team = await require_team(session, user, team_id, administer=True) duplicate = await session.scalar( select(Team.id).where( Team.organization_id == user.organization_id, @@ -1054,13 +1037,6 @@ async def update_team( raise HTTPException( status_code=409, detail="A team with this name already exists" ) - team = await session.scalar( - select(Team) - .where(Team.organization_id == user.organization_id, Team.id == team_id) - .with_for_update() - ) - if team is None: - raise HTTPException(status_code=404, detail="Team not found") before = TeamView.model_validate(team).model_dump(mode="json") for field, value in payload.model_dump().items(): setattr(team, field, value) @@ -2311,8 +2287,7 @@ def _request_summary_statement(tenant_id: UUID, filters: list[Any]): AuditIntent.organization_id == tenant_id, AuditIntent.request_id == RequestLog.request_id, AuditIntent.event_type == "preflight", - AuditIntent.usage_summary["denial_reason"].as_string() - == "spend_limit_exceeded", + AuditIntent.usage_summary["spend_denied"].as_integer() == 1, ) .correlate(RequestLog) .exists() diff --git a/ee/src/shim_enterprise/billing/ledger.py b/ee/src/shim_enterprise/billing/ledger.py index 7a0a210..399a93d 100644 --- a/ee/src/shim_enterprise/billing/ledger.py +++ b/ee/src/shim_enterprise/billing/ledger.py @@ -23,6 +23,7 @@ SpendPeriodUsage, UsageLedger, ) +from shim_enterprise.tenants.models import Organization from shim_enterprise.gateway.pipeline.audit_intent import ( AuditIntentPersistenceError, AuditIntentRepository, @@ -159,6 +160,7 @@ class QuotaPolicySnapshot: monthly_token_limit: int | None team_id: UUID | None = None team_policy: QuotaPolicySnapshot | None = None + organization_policy: QuotaPolicySnapshot | None = None def __post_init__(self) -> None: limits = ( @@ -322,6 +324,8 @@ async def reserve_quota( session: AsyncSession, command: QuotaReservationCommand, ) -> ReservationResult: + if command.policy.organization_policy is not None: + await self._lock_organization(session, command.tenant_id) await RequestLifecycleRepository.create( session, organization_id=command.tenant_id, @@ -548,6 +552,11 @@ async def _finalize_locked( quota_reservation: UsageLedger, spend_reservation: UsageLedger | None, ) -> FinalizationResult: + if not any( + allocation.get("scope") == "organization" + for allocation in quota_reservation.period_allocations + ): + await self._lock_organization(session, command.tenant_id) quota_event, quota_replayed = await self._transition_reservation( session, command.tenant_id, @@ -879,8 +888,20 @@ async def _reserve_quota_periods( session: AsyncSession, command: QuotaReservationCommand, ) -> list[dict[str, object]]: - allocations = await self._reserve_scoped_quota_periods( - session, command, command.policy + allocations: list[dict[str, object]] = [] + if command.policy.organization_policy is not None: + allocations.extend( + await self._reserve_scoped_quota_periods( + session, + command, + command.policy.organization_policy, + scope="organization", + ) + ) + allocations.extend( + await self._reserve_scoped_quota_periods( + session, command, command.policy, scope="api_key" + ) ) if ( command.policy.team_id is not None @@ -891,6 +912,7 @@ async def _reserve_quota_periods( session, command, command.policy.team_policy, + scope="team", team_id=command.policy.team_id, ) ) @@ -902,6 +924,7 @@ async def _reserve_scoped_quota_periods( command: QuotaReservationCommand, policy: QuotaPolicySnapshot, *, + scope: Literal["organization", "api_key", "team"], team_id: UUID | None = None, ) -> list[dict[str, object]]: token_delta = command.estimated_input_tokens + command.maximum_output_tokens @@ -952,6 +975,7 @@ async def _reserve_scoped_quota_periods( token_delta=tokens, request_limit=request_limit, token_limit=token_limit, + scope=scope, team_id=team_id, ) if row is None: @@ -959,6 +983,7 @@ async def _reserve_scoped_quota_periods( allocations.append( { "counter_type": "quota", + "scope": scope, "team_id": str(team_id) if team_id else None, "period_row_id": str(row.id), "period_type": period_type, @@ -982,12 +1007,27 @@ async def _conditional_quota_upsert( token_delta: int, request_limit: int | None, token_limit: int | None, + scope: Literal["organization", "api_key", "team"], team_id: UUID | None = None, ) -> QuotaPeriodUsage | None: + if scope == "organization": + initial = await self._reserve_initial_organization_quota_period( + session, + command, + period_type=period_type, + period_start=period_start, + period_end=period_end, + request_delta=request_delta, + token_delta=token_delta, + request_limit=request_limit, + token_limit=token_limit, + ) + if initial is not None: + return initial statement = insert(QuotaPeriodUsage).values( organization_id=command.tenant_id, - api_key_id=command.api_key_id if team_id is None else None, - team_id=team_id, + api_key_id=command.api_key_id if scope == "api_key" else None, + team_id=team_id if scope == "team" else None, period_type=period_type, period_start=period_start, period_end=period_end, @@ -997,18 +1037,28 @@ async def _conditional_quota_upsert( limit_tokens=token_limit, ) excluded = statement.excluded + scope_column = ( + QuotaPeriodUsage.api_key_id + if scope == "api_key" + else QuotaPeriodUsage.team_id + if scope == "team" + else None + ) statement = statement.on_conflict_do_update( index_elements=[ QuotaPeriodUsage.organization_id, - QuotaPeriodUsage.api_key_id - if team_id is None - else QuotaPeriodUsage.team_id, + *([scope_column] if scope_column is not None else []), QuotaPeriodUsage.period_type, QuotaPeriodUsage.period_start, ], - index_where=QuotaPeriodUsage.team_id.is_not(None) - if team_id is not None - else None, + index_where=( + QuotaPeriodUsage.api_key_id.is_(None) + & QuotaPeriodUsage.team_id.is_(None) + if scope == "organization" + else QuotaPeriodUsage.team_id.is_not(None) + if scope == "team" + else None + ), set_={ "reserved_requests": ( QuotaPeriodUsage.reserved_requests + excluded.reserved_requests @@ -1039,6 +1089,84 @@ async def _conditional_quota_upsert( ).returning(QuotaPeriodUsage) return (await session.execute(statement)).scalar_one_or_none() + async def _reserve_initial_organization_quota_period( + self, + session: AsyncSession, + command: QuotaReservationCommand, + *, + period_type: str, + period_start: date, + period_end: date, + request_delta: int, + token_delta: int, + request_limit: int | None, + token_limit: int | None, + ) -> QuotaPeriodUsage | None: + if await session.scalar( + select(QuotaPeriodUsage.id).where( + QuotaPeriodUsage.organization_id == command.tenant_id, + QuotaPeriodUsage.api_key_id.is_(None), + QuotaPeriodUsage.team_id.is_(None), + QuotaPeriodUsage.period_type == period_type, + QuotaPeriodUsage.period_start == period_start, + ) + ): + return None + existing = tuple( + ( + await session.execute( + select(QuotaPeriodUsage) + .where( + QuotaPeriodUsage.organization_id == command.tenant_id, + QuotaPeriodUsage.api_key_id.is_not(None), + QuotaPeriodUsage.period_type == period_type, + QuotaPeriodUsage.period_start == period_start, + ) + .with_for_update() + ) + ).scalars() + ) + reserved_requests = sum(row.reserved_requests for row in existing) + settled_requests = sum(row.settled_requests for row in existing) + reserved_tokens = sum(row.reserved_tokens for row in existing) + settled_tokens = sum(row.settled_tokens for row in existing) + if ( + request_limit is not None + and reserved_requests + settled_requests + request_delta > request_limit + ): + raise QuotaLimitExceeded(f"{period_type} request quota exceeded") + if ( + token_limit is not None + and reserved_tokens + settled_tokens + token_delta > token_limit + ): + raise QuotaLimitExceeded(f"{period_type} token quota exceeded") + statement = insert(QuotaPeriodUsage).values( + organization_id=command.tenant_id, + api_key_id=None, + team_id=None, + period_type=period_type, + period_start=period_start, + period_end=period_end, + reserved_requests=reserved_requests + request_delta, + settled_requests=settled_requests, + reserved_tokens=reserved_tokens + token_delta, + settled_tokens=settled_tokens, + limit_requests=request_limit, + limit_tokens=token_limit, + ) + statement = statement.on_conflict_do_nothing( + index_elements=[ + QuotaPeriodUsage.organization_id, + QuotaPeriodUsage.period_type, + QuotaPeriodUsage.period_start, + ], + index_where=( + QuotaPeriodUsage.api_key_id.is_(None) + & QuotaPeriodUsage.team_id.is_(None) + ), + ).returning(QuotaPeriodUsage) + return (await session.execute(statement)).scalar_one_or_none() + async def _reserve_spend_period( self, session: AsyncSession, @@ -1362,6 +1490,20 @@ async def _lock_lifecycle( raise AccountingConflictError("request lifecycle does not exist") return lifecycle + async def _lock_organization( + self, + session: AsyncSession, + tenant_id: TenantId, + ) -> Organization: + organization = await session.scalar( + select(Organization) + .where(Organization.id == tenant_id) + .with_for_update(of=Organization) + ) + if organization is None: + raise AccountingConflictError("accounting organization does not exist") + return organization + async def _lock_reservation( self, session: AsyncSession, @@ -1504,16 +1646,23 @@ async def _apply_period_transitions( completion_tokens: int, cost_usd: Decimal, ) -> list[dict[str, object]]: - allocations = sorted( - reservation.period_allocations, + allocations = [dict(item) for item in reservation.period_allocations] + allocations.extend( + await self._legacy_organization_allocations(session, tenant_id, allocations) + ) + allocations.sort( key=lambda item: ( str(item.get("counter_type")), - # Match admission's key-then-team order to avoid lock inversion. - bool(item.get("team_id")), + # Match admission's organization-key-team order to avoid lock inversion. + 0 + if item.get("scope") == "organization" + else 2 + if item.get("scope") == "team" or item.get("team_id") + else 1, str(item.get("period_type")), str(item.get("period_start")), str(item.get("period_row_id")), - ), + ) ) if not allocations: raise AccountingConflictError("reservation has no period allocations") @@ -1547,6 +1696,42 @@ async def _apply_period_transitions( raise AccountingConflictError("unknown reservation counter type") return terminal + async def _legacy_organization_allocations( + self, + session: AsyncSession, + tenant_id: TenantId, + allocations: list[dict[str, object]], + ) -> list[dict[str, object]]: + if any(item.get("scope") == "organization" for item in allocations): + return [] + legacy: list[dict[str, object]] = [] + for allocation in allocations: + if ( + allocation.get("counter_type") != "quota" + or allocation.get("period_type") != "monthly" + or allocation.get("scope") in {"organization", "team"} + or allocation.get("team_id") + ): + continue + organization_row_id = await session.scalar( + select(QuotaPeriodUsage.id).where( + QuotaPeriodUsage.organization_id == tenant_id, + QuotaPeriodUsage.api_key_id.is_(None), + QuotaPeriodUsage.team_id.is_(None), + QuotaPeriodUsage.period_start + == date.fromisoformat(str(allocation["period_start"])), + ) + ) + if organization_row_id is not None: + legacy.append( + { + **allocation, + "scope": "organization", + "period_row_id": str(organization_row_id), + } + ) + return legacy + async def _apply_quota_transition( self, session: AsyncSession, diff --git a/ee/src/shim_enterprise/billing/models.py b/ee/src/shim_enterprise/billing/models.py index 7ef5075..9088f53 100644 --- a/ee/src/shim_enterprise/billing/models.py +++ b/ee/src/shim_enterprise/billing/models.py @@ -352,7 +352,7 @@ class UsageLedger(Base): class QuotaPeriodUsage(Base): - """Authoritative daily or monthly key or team quota counters.""" + """Authoritative daily or monthly organization, key, or team counters.""" __tablename__ = "quota_period_usage" __table_args__ = ( @@ -367,9 +367,17 @@ class QuotaPeriodUsage(Base): name="fk_quota_period_usage_org_team", ), CheckConstraint( - "(api_key_id IS NULL) <> (team_id IS NULL)", + "NOT (api_key_id IS NOT NULL AND team_id IS NOT NULL)", name="ck_quota_period_usage_single_scope", ), + Index( + "uq_quota_period_usage_organization_scope", + "organization_id", + "period_type", + "period_start", + unique=True, + postgresql_where=text("api_key_id IS NULL AND team_id IS NULL"), + ), Index( "uq_quota_period_usage_team_scope", "organization_id", diff --git a/ee/src/shim_enterprise/gateway/pipeline/quota_reservation.py b/ee/src/shim_enterprise/gateway/pipeline/quota_reservation.py index 9bb6b18..087b117 100644 --- a/ee/src/shim_enterprise/gateway/pipeline/quota_reservation.py +++ b/ee/src/shim_enterprise/gateway/pipeline/quota_reservation.py @@ -59,6 +59,7 @@ from shim_enterprise.outbox.publisher import OutboxWriter from shim_enterprise.tenants.models import ( ApiKey, + Organization, ProviderSecret, TierDefinition, Team, @@ -141,6 +142,37 @@ async def quota( session: AsyncSession, prepared: PreparedInference, ) -> QuotaPolicySnapshot: + # Also fence cap activation against admissions that began uncapped. + # ponytail: this serializes short admissions per organization; use a shared + # activation barrier if measured on-prem contention warrants it. + organization = ( + await session.execute( + select(Organization) + .where(Organization.id == prepared.tenant_id) + .with_for_update(of=Organization) + ) + ).scalar_one_or_none() + if organization is None: + raise AccountingPersistenceError("accounting organization no longer exists") + organization_limits = ( + self._unlimited(organization.quota_monthly_request_limit), + self._unlimited(organization.quota_monthly_token_limit), + ) + organization_policy = ( + QuotaPolicySnapshot( + version=self._version( + "organization:" + f"{organization.id}:{organization.billing_revision}:" + f"{organization.updated_at}", + organization_limits, + ), + daily_request_limit=None, + monthly_request_limit=organization_limits[0], + monthly_token_limit=organization_limits[1], + ) + if any(limit is not None for limit in organization_limits) + else None + ) api_key_statement = ( select(ApiKey) .where( @@ -233,13 +265,18 @@ async def quota( return QuotaPolicySnapshot( version=self._version( "quota-default", - (*values, team_policy.version if team_policy else None), + ( + *values, + organization_policy.version if organization_policy else None, + team_policy.version if team_policy else None, + ), ), daily_request_limit=values[0], monthly_request_limit=values[1], monthly_token_limit=values[2], team_id=api_key.team_id, team_policy=team_policy, + organization_policy=organization_policy, ) values = ( @@ -250,13 +287,18 @@ async def quota( return QuotaPolicySnapshot( version=self._version( f"quota:{tier.slug}:{getattr(tier, 'updated_at', None)}", - (*values, team_policy.version if team_policy else None), + ( + *values, + organization_policy.version if organization_policy else None, + team_policy.version if team_policy else None, + ), ), daily_request_limit=values[0], monthly_request_limit=values[1], monthly_token_limit=values[2], team_id=api_key.team_id, team_policy=team_policy, + organization_policy=organization_policy, ) async def spend( diff --git a/ee/src/shim_enterprise/observability/overview.py b/ee/src/shim_enterprise/observability/overview.py index 6f4c5ca..6b31f1e 100644 --- a/ee/src/shim_enterprise/observability/overview.py +++ b/ee/src/shim_enterprise/observability/overview.py @@ -321,8 +321,7 @@ def _spend_denied(tenant_id: UUID): AuditIntent.organization_id == tenant_id, AuditIntent.request_id == RequestLifecycle.request_id, AuditIntent.event_type == "preflight", - AuditIntent.usage_summary["denial_reason"].as_string() - == "spend_limit_exceeded", + AuditIntent.usage_summary["spend_denied"].as_integer() == 1, ) .correlate(RequestLifecycle) .exists() diff --git a/ee/src/shim_enterprise/tenants/models.py b/ee/src/shim_enterprise/tenants/models.py index 0f984ad..396cf11 100644 --- a/ee/src/shim_enterprise/tenants/models.py +++ b/ee/src/shim_enterprise/tenants/models.py @@ -31,6 +31,16 @@ class Organization(Base, TimestampMixin): """Mandatory tenant boundary for every customer-owned record.""" __tablename__ = "organizations" + __table_args__ = ( + CheckConstraint( + "quota_monthly_request_limit IS NULL OR quota_monthly_request_limit >= 0", + name="ck_organizations_quota_monthly_requests", + ), + CheckConstraint( + "quota_monthly_token_limit IS NULL OR quota_monthly_token_limit >= 0", + name="ck_organizations_quota_monthly_tokens", + ), + ) id: Mapped[UUID] = mapped_column( SqlUUID(as_uuid=True), primary_key=True, default=uuid4 @@ -58,6 +68,11 @@ class Organization(Base, TimestampMixin): ) billing_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) customer_portal_url: Mapped[str | None] = mapped_column(Text) + quota_monthly_request_limit: Mapped[int | None] = mapped_column(Integer) + quota_monthly_token_limit: Mapped[int | None] = mapped_column(Integer) + billing_revision: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) users: Mapped[list[User]] = relationship( back_populates="organization", cascade="all, delete-orphan" diff --git a/ee/src/shim_enterprise/tenants/plans.py b/ee/src/shim_enterprise/tenants/plans.py index 04d854c..5db5ff8 100644 --- a/ee/src/shim_enterprise/tenants/plans.py +++ b/ee/src/shim_enterprise/tenants/plans.py @@ -1,12 +1,13 @@ """Operator-managed organization plans and inherited API-key tiers.""" +from dataclasses import dataclass from datetime import datetime, timezone from uuid import UUID, uuid4 from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from shim_enterprise.tenants.models import ApiKey, Organization, TierDefinition +from shim_enterprise.tenants.models import ApiKey, Organization, TierDefinition, User from shim_enterprise.tenants.service import ensure_privacy_defaults @@ -33,21 +34,30 @@ async def activate_organization_plan( organization_id: UUID, tier: str, ) -> Organization: - if await session.get(TierDefinition, tier) is None: - raise ValueError(f"Unknown tier: {tier}") - organization = await session.scalar( - select(Organization) - .where(Organization.id == organization_id) - .with_for_update(of=Organization) - ) - if organization is None: - raise ValueError(f"Organization not found: {organization_id}") - organization.tier = tier + organization = await _locked_organization(session, organization_id) + await _apply_tier(session, organization, tier) organization.billing_status = "free" if tier == "free" else "active" organization.billing_source = "operator" organization.billing_event_at = datetime.now(timezone.utc) + organization.billing_revision += 1 # Retain legacy billing references and period fields as historical records. await session.flush() + return organization + + +async def _apply_tier( + session: AsyncSession, organization: Organization, tier: str +) -> TierDefinition: + definition = await session.get(TierDefinition, tier) + if definition is None: + raise ValueError(f"Unknown tier: {tier}") + organization.tier = tier + if ( + organization.quota_monthly_request_limit is not None + or organization.quota_monthly_token_limit is not None + ): + organization.quota_monthly_request_limit = definition.monthly_request_limit + organization.quota_monthly_token_limit = definition.monthly_token_limit await session.execute( update(ApiKey) .where( @@ -56,4 +66,165 @@ async def activate_organization_plan( ) .values(tier=tier) ) + return definition + + +async def _locked_organization( + session: AsyncSession, organization_id: UUID +) -> Organization: + organization = await session.scalar( + select(Organization) + .where(Organization.id == organization_id) + .execution_options(populate_existing=True) + .with_for_update(of=Organization) + ) + if organization is None: + raise ValueError(f"Organization not found: {organization_id}") return organization + + +@dataclass(frozen=True) +class OrganizationPlan: + organization_id: UUID + tier: str + source: str | None + status: str + customer_id: str | None + subscription_id: str | None + current_period_end: datetime | None + cancel_at_period_end: bool + revision: int + + +def _plan(organization: Organization) -> OrganizationPlan: + return OrganizationPlan( + organization.id, + organization.tier, + organization.billing_source, + organization.billing_status, + organization.external_customer_id, + organization.external_subscription_id, + organization.current_period_end, + organization.cancel_at_period_end, + organization.billing_revision, + ) + + +async def organization_plan( + session: AsyncSession, organization_id: UUID, *, lock: bool = False +) -> OrganizationPlan: + organization = ( + await _locked_organization(session, organization_id) + if lock + else await session.get(Organization, organization_id, populate_existing=True) + ) + if organization is None: + raise ValueError("Organization does not exist") + return _plan(organization) + + +async def claim_billing_source( + session: AsyncSession, organization_id: UUID, source: str +) -> OrganizationPlan: + """Bind a free tenant to an explicitly selected provisioning authority.""" + organization = await _locked_organization(session, organization_id) + if organization.billing_source not in {None, source} or ( + organization.billing_source is None and organization.tier != "free" + ): + raise ValueError("This organization has an operator-managed plan") + if organization.billing_source is None: + organization.billing_source = source + organization.billing_revision += 1 + await session.flush() + return _plan(organization) + + +async def apply_billing_plan( + session: AsyncSession, + organization_id: UUID, + *, + expected_revision: int, + source: str, + status: str, + tier: str, + customer_id: str, + subscription_id: str | None, + product_id: str | None, + current_period_end: datetime | None, + cancel_at_period_end: bool, +) -> bool: + """Apply a verified billing snapshot and inherited keys in one transaction.""" + organization = await _locked_organization(session, organization_id) + if ( + organization.billing_revision != expected_revision + or organization.billing_source != source + ): + return False + if organization.external_customer_id not in {None, customer_id}: + raise ValueError("Billing customer does not match this organization") + definition = await _apply_tier(session, organization, tier) + organization.billing_status = status + organization.external_customer_id = customer_id + organization.external_subscription_id = subscription_id + organization.billing_variant_id = product_id + organization.current_period_end = current_period_end + organization.cancel_at_period_end = cancel_at_period_end + organization.billing_event_at = datetime.now(timezone.utc) + organization.billing_revision += 1 + organization.quota_monthly_request_limit = definition.monthly_request_limit + organization.quota_monthly_token_limit = definition.monthly_token_limit + await session.flush() + return True + + +async def billing_organization_ids( + session: AsyncSession, source: str, *, after: UUID | None = None, limit: int = 100 +) -> tuple[UUID, ...]: + query = select(Organization.id).where(Organization.billing_source == source) + if after is not None: + query = query.where(Organization.id > after) + return tuple( + (await session.scalars(query.order_by(Organization.id).limit(limit))).all() + ) + + +async def configure_organization_quota( + session: AsyncSession, organization_id: UUID +) -> None: + """Opt an organization into the current tier's shared monthly allowance.""" + organization = await session.get( + Organization, organization_id, populate_existing=True + ) + if organization is None: + raise ValueError(f"Organization not found: {organization_id}") + definition = await session.get(TierDefinition, organization.tier) + if definition is None: + raise ValueError("Organization tier does not exist") + if ( + organization.quota_monthly_request_limit == definition.monthly_request_limit + and organization.quota_monthly_token_limit == definition.monthly_token_limit + ): + return + + organization = await _locked_organization(session, organization_id) + definition = await session.get( + TierDefinition, organization.tier, populate_existing=True + ) + if definition is None: + raise ValueError("Organization tier does not exist") + organization.quota_monthly_request_limit = definition.monthly_request_limit + organization.quota_monthly_token_limit = definition.monthly_token_limit + await session.flush() + + +async def billing_owner_email( + session: AsyncSession, organization_id: UUID, user_id: UUID +) -> str | None: + return await session.scalar( + select(User.email).where( + User.id == user_id, + User.organization_id == organization_id, + User.role == "owner", + User.is_active.is_(True), + ) + ) diff --git a/ee/src/shim_enterprise/workers/outbox.py b/ee/src/shim_enterprise/workers/outbox.py index c4c5448..8eb97e3 100644 --- a/ee/src/shim_enterprise/workers/outbox.py +++ b/ee/src/shim_enterprise/workers/outbox.py @@ -350,7 +350,7 @@ def _worker_id() -> str: return f"{socket.gethostname()}:{os.getpid()}:{uuid4().hex[:12]}" -async def main() -> None: +async def main(publisher: OutboxPublisher | None = None) -> None: from shim_enterprise.outbox.handlers import build_publisher configure_logging(settings.LOG_LEVEL) @@ -368,7 +368,7 @@ async def main() -> None: for shutdown_signal in shutdown_signals: loop.add_signal_handler(shutdown_signal, stop_event.set) try: - await OutboxWorker(build_publisher()).run(stop_event) + await OutboxWorker(publisher or build_publisher()).run(stop_event) finally: for shutdown_signal in shutdown_signals: loop.remove_signal_handler(shutdown_signal) diff --git a/ee/tests/gateway/kernel/test_accounting_coordinator.py b/ee/tests/gateway/kernel/test_accounting_coordinator.py index 7affe7b..358f8a7 100644 --- a/ee/tests/gateway/kernel/test_accounting_coordinator.py +++ b/ee/tests/gateway/kernel/test_accounting_coordinator.py @@ -1807,6 +1807,15 @@ async def test_quota_policy_uses_shared_tier_lock_and_exclusive_key_lock(): scalar=AsyncMock(return_value=SimpleNamespace(role="member")), execute=AsyncMock( side_effect=[ + SimpleNamespace( + scalar_one_or_none=lambda: SimpleNamespace( + id=uuid4(), + billing_revision=0, + updated_at=None, + quota_monthly_request_limit=None, + quota_monthly_token_limit=None, + ) + ), SimpleNamespace( scalar_one_or_none=lambda: SimpleNamespace( tier="free", @@ -1834,8 +1843,9 @@ async def test_quota_policy_uses_shared_tier_lock_and_exclusive_key_lock(): str(call.args[0].compile(dialect=postgresql.dialect())) for call in session.execute.await_args_list ] - assert statements[0].endswith("FOR UPDATE") - assert statements[1].endswith("FOR SHARE") + assert statements[0].endswith("FOR UPDATE OF organizations") + assert statements[1].endswith("FOR UPDATE") + assert statements[2].endswith("FOR SHARE") assert policy.daily_request_limit == 10 diff --git a/ee/tests/observability/test_enterprise_observability.py b/ee/tests/observability/test_enterprise_observability.py index 68a2958..36be9d1 100644 --- a/ee/tests/observability/test_enterprise_observability.py +++ b/ee/tests/observability/test_enterprise_observability.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import subprocess import sys from types import SimpleNamespace @@ -8,7 +9,9 @@ from uuid import uuid4 import pytest +from sqlalchemy.dialects import postgresql +from shim_enterprise.gateway.contracts.audit import validate_audit_intent from shim_enterprise.observability.lifecycle import ( PersistenceConflictError, RequestLifecycleRepository, @@ -103,3 +106,66 @@ async def test_lifecycle_create_allows_replay_after_mutable_state_progresses() - ) assert replayed is existing + + +@pytest.mark.asyncio +async def test_spend_denial_audit_matches_every_audit_reader( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from shim_enterprise.api.v1.management import _request_summary_statement + from shim_enterprise.billing import ledger + from shim_enterprise.observability.overview import _spend_denied + + captured: dict[str, object] = {} + + async def create(session, *, organization_id, values): + captured.update(values) + + monkeypatch.setattr(ledger.AuditIntentRepository, "create", create) + monkeypatch.setattr( + ledger.RequestLifecycleRepository, + "get", + AsyncMock( + return_value=SimpleNamespace( + lifecycle_metadata={}, + actor_type="api_key", + api_key_id=uuid4(), + user_id=None, + ) + ), + ) + + command = SimpleNamespace( + tenant_id=uuid4(), + request_id="req_spend_denied", + policy_verdicts=(), + audit_policy_mode="strict", + input_hash="a" * 64, + pii_entities=None, + provider="openai", + provider_model="gpt-5", + ) + repository = ledger.DurableAccountingRepository() + await repository.write_spend_denial_preflight(AsyncMock(), command) + + validate_audit_intent(uuid4(), {**captured, "tenant_id": uuid4()}) + + summary = captured["usage_summary"] + assert captured["lifecycle_status"] == "spend_denied" + assert summary["spend_denied"] == 1 + + for statement in ( + _spend_denied(uuid4()), + _request_summary_statement(uuid4(), []), + ): + compiled = statement.compile(dialect=postgresql.dialect()) + match = re.search( + r"usage_summary ->> %\((\w+)\)s\) AS INTEGER\) = %\((\w+)\)s", + str(compiled), + ) + assert match is not None, "reader no longer compares a usage_summary value" + key_param, value_param = match.groups() + assert (compiled.params[key_param], compiled.params[value_param]) == ( + "spend_denied", + 1, + ) diff --git a/ee/tests/tenants/test_plans.py b/ee/tests/tenants/test_plans.py index fb6d818..b6c049f 100644 --- a/ee/tests/tenants/test_plans.py +++ b/ee/tests/tenants/test_plans.py @@ -4,7 +4,7 @@ from decimal import Decimal from types import SimpleNamespace from unittest.mock import AsyncMock -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from sqlalchemy import select @@ -19,7 +19,10 @@ User, ) from shim_enterprise.tenants.plans import ( + OrganizationPlan, + _plan, activate_organization_plan, + configure_organization_quota, create_organization_plan, ) from shim_enterprise.tenants.service import ( @@ -286,3 +289,132 @@ async def test_free_plan_cannot_add_team_members( test_user_with_org, db, ) + + +def test_organization_plan_maps_organization_columns_in_field_order() -> None: + organization = SimpleNamespace( + id=uuid4(), + tier="agency", + billing_source="operator", + billing_status="active", + external_customer_id="customer-1", + external_subscription_id="subscription-1", + current_period_end=datetime(2026, 9, 1, tzinfo=timezone.utc), + cancel_at_period_end=True, + billing_revision=7, + ) + + assert _plan(organization) == OrganizationPlan( + organization_id=organization.id, + tier="agency", + source="operator", + status="active", + customer_id="customer-1", + subscription_id="subscription-1", + current_period_end=organization.current_period_end, + cancel_at_period_end=True, + revision=7, + ) + + +def _organization(organization_id: UUID, **overrides) -> SimpleNamespace: + fields: dict[str, object] = { + "id": organization_id, + "tier": "free", + "quota_monthly_request_limit": None, + "quota_monthly_token_limit": None, + } + fields.update(overrides) + return SimpleNamespace(**fields) + + +def _tier(request_limit: int, token_limit: int) -> SimpleNamespace: + return SimpleNamespace( + monthly_request_limit=request_limit, monthly_token_limit=token_limit + ) + + +def _session_get( + organizations: list[SimpleNamespace | None], + tiers: list[SimpleNamespace | None] | None = None, +) -> AsyncMock: + pending_organizations = list(organizations) + pending_tiers = list(tiers or []) + + async def get(model, *_args, **_kwargs): + if model is Organization: + return pending_organizations.pop(0) + return pending_tiers.pop(0) + + return AsyncMock(side_effect=get) + + +@pytest.mark.asyncio +async def test_quota_configuration_keeps_its_errors_for_missing_rows() -> None: + organization_id = uuid4() + session = SimpleNamespace( + get=_session_get( + organizations=[None, _organization(organization_id, tier="gone")], + tiers=[None], + ), + scalar=AsyncMock(), + flush=AsyncMock(), + ) + with pytest.raises(ValueError, match="Organization not found"): + await configure_organization_quota(session, organization_id) + with pytest.raises(ValueError, match="Organization tier does not exist"): + await configure_organization_quota(session, organization_id) + + session.scalar.assert_not_awaited() + session.flush.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_quota_configuration_skips_the_row_lock_in_the_steady_state() -> None: + organization_id = uuid4() + session = SimpleNamespace( + get=_session_get( + organizations=[ + _organization( + organization_id, + quota_monthly_request_limit=1000, + quota_monthly_token_limit=1_000_000, + ) + ], + tiers=[_tier(1000, 1_000_000)], + ), + scalar=AsyncMock(), + flush=AsyncMock(), + ) + + await configure_organization_quota(session, organization_id) + + session.scalar.assert_not_awaited() + session.flush.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_quota_configuration_locks_and_rereads_the_tier_on_drift() -> None: + organization_id = uuid4() + locked = _organization(organization_id, tier="agency") + session = SimpleNamespace( + get=_session_get( + organizations=[_organization(organization_id, tier="managed")], + tiers=[_tier(5000, 5_000_000), _tier(50000, 50_000_000)], + ), + scalar=AsyncMock(return_value=locked), + flush=AsyncMock(), + ) + + await configure_organization_quota(session, organization_id) + + statement = session.scalar.await_args_list[0].args[0] + assert "FOR UPDATE OF organizations" in str( + statement.compile(dialect=postgresql.dialect()) + ) + assert session.get.await_args_list[-1].kwargs == {"populate_existing": True} + assert (locked.quota_monthly_request_limit, locked.quota_monthly_token_limit) == ( + 50000, + 50_000_000, + ) + session.flush.assert_awaited_once() diff --git a/ee/tests/tenants/test_teams.py b/ee/tests/tenants/test_teams.py index c48e311..f27d0aa 100644 --- a/ee/tests/tenants/test_teams.py +++ b/ee/tests/tenants/test_teams.py @@ -385,3 +385,214 @@ async def reserve(key_id): await session.execute( delete(Organization).where(Organization.id == organization_id) ) + + +@pytest.mark.asyncio +async def test_organization_quota_shares_existing_usage_across_new_keys(async_engine): + factory = async_sessionmaker(async_engine, expire_on_commit=False) + organization_id, user_id = uuid4(), uuid4() + repository = DurableAccountingRepository() + async with factory.begin() as session: + session.add( + Organization( + id=organization_id, + name="Organization quota", + slug=f"organization-quota-{organization_id}", + ) + ) + session.add( + User( + id=user_id, + organization_id=organization_id, + email=f"organization-quota-{user_id}@example.com", + role="owner", + is_active=True, + is_verified=True, + ) + ) + await session.flush() + _, legacy_key = await create_api_key(session, user_id=user_id, name="legacy") + started_at = datetime.now(timezone.utc) + legacy_request = f"req_legacy_{uuid4().hex}" + await repository.reserve_quota( + session, + QuotaReservationCommand( + tenant_id=organization_id, + api_key_id=legacy_key.id, + request_id=legacy_request, + requested_model="internal", + source_endpoint="chat.completions", + started_at=started_at, + reconciliation_due_at=started_at + timedelta(minutes=2), + estimated_input_tokens=2, + maximum_output_tokens=8, + policy=QuotaPolicySnapshot("legacy", None, None, None), + ), + ) + await repository.finalize( + session, + FinalizationCommand( + tenant_id=organization_id, + request_id=legacy_request, + quota_action=TerminalAction.SETTLE, + prompt_tokens=2, + completion_tokens=3, + lifecycle_status="completed", + ), + ) + legacy_active_request = f"req_legacy_active_{uuid4().hex}" + await repository.reserve_quota( + session, + QuotaReservationCommand( + tenant_id=organization_id, + api_key_id=legacy_key.id, + request_id=legacy_active_request, + requested_model="internal", + source_endpoint="chat.completions", + started_at=started_at, + reconciliation_due_at=started_at + timedelta(minutes=2), + estimated_input_tokens=2, + maximum_output_tokens=8, + policy=QuotaPolicySnapshot("legacy", None, None, None), + ), + ) + + async with factory.begin() as session: + organization = await session.get(Organization, organization_id) + assert organization is not None + organization.quota_monthly_request_limit = 4 + organization.quota_monthly_token_limit = 35 + organization.billing_revision += 1 + _, first_new_key = await create_api_key( + session, user_id=user_id, name="first new" + ) + _, second_new_key = await create_api_key( + session, user_id=user_id, name="second new" + ) + + async def reserve(api_key_id): + request_id = f"req_organization_{uuid4().hex}" + now = datetime.now(timezone.utc) + async with factory() as session: + policy = await AccountingPolicyLoader().quota( + session, + SimpleNamespace( + tenant_id=organization_id, + api_key_id=api_key_id, + model="internal", + ), + ) + try: + await repository.reserve_quota( + session, + QuotaReservationCommand( + tenant_id=organization_id, + api_key_id=api_key_id, + request_id=request_id, + requested_model="internal", + source_endpoint="chat.completions", + started_at=now, + reconciliation_due_at=now + timedelta(minutes=2), + estimated_input_tokens=2, + maximum_output_tokens=8, + policy=policy, + ), + ) + await session.commit() + return request_id + except QuotaLimitExceeded: + await session.rollback() + return None + + try: + first, second = await asyncio.gather( + reserve(first_new_key.id), reserve(second_new_key.id) + ) + assert first is not None and second is not None + assert await reserve(legacy_key.id) is None + + async with factory.begin() as session: + counter = await session.scalar( + select(QuotaPeriodUsage).where( + QuotaPeriodUsage.organization_id == organization_id, + QuotaPeriodUsage.api_key_id.is_(None), + QuotaPeriodUsage.team_id.is_(None), + ) + ) + assert counter is not None + assert (counter.reserved_requests, counter.settled_requests) == (3, 1) + assert (counter.reserved_tokens, counter.settled_tokens) == (30, 5) + await repository.finalize( + session, + FinalizationCommand( + tenant_id=organization_id, + request_id=legacy_active_request, + quota_action=TerminalAction.SETTLE, + prompt_tokens=2, + completion_tokens=3, + lifecycle_status="completed", + ), + ) + await repository.finalize( + session, + FinalizationCommand( + tenant_id=organization_id, + request_id=first, + quota_action=TerminalAction.REFUND, + lifecycle_status="failed", + terminal_error_code="REQUEST_ABORTED", + ), + ) + await repository.finalize( + session, + FinalizationCommand( + tenant_id=organization_id, + request_id=second, + quota_action=TerminalAction.SETTLE, + prompt_tokens=2, + completion_tokens=4, + lifecycle_status="completed", + ), + ) + + replacement = await reserve(legacy_key.id) + assert replacement is not None + async with factory.begin() as session: + await repository.finalize( + session, + FinalizationCommand( + tenant_id=organization_id, + request_id=replacement, + quota_action=TerminalAction.SETTLE, + prompt_tokens=2, + completion_tokens=5, + lifecycle_status="completed", + ), + ) + counter = await session.scalar( + select(QuotaPeriodUsage).where( + QuotaPeriodUsage.organization_id == organization_id, + QuotaPeriodUsage.api_key_id.is_(None), + QuotaPeriodUsage.team_id.is_(None), + ) + ) + assert counter is not None + assert (counter.reserved_requests, counter.settled_requests) == (0, 4) + assert (counter.reserved_tokens, counter.settled_tokens) == (0, 23) + assert await reserve(legacy_key.id) is None + finally: + async with factory.begin() as session: + for model in ( + OutboxEvent, + UsageLedger, + RequestLifecycle, + QuotaPeriodUsage, + ApiKey, + User, + ): + await session.execute( + delete(model).where(model.organization_id == organization_id) + ) + await session.execute( + delete(Organization).where(Organization.id == organization_id) + ) diff --git a/pyproject.toml b/pyproject.toml index 545614e..e0e78ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,10 +42,13 @@ dev = [ required-version = ">=0.12.5,<0.13" [tool.uv.workspace] -members = ["ee"] +members = ["ee", "ee/cloud"] + +[tool.uv.sources] +shim-enterprise = { workspace = true } [tool.ty.src] -include = ["src", "ee/src"] +include = ["src", "ee/src", "ee/cloud/src"] [tool.pytest.ini_options] pythonpath = ["."] diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py index 9f33cdb..9628c27 100644 --- a/scripts/export_openapi.py +++ b/scripts/export_openapi.py @@ -10,6 +10,7 @@ DEFAULT_OUTPUTS = { "community": REPOSITORY_ROOT / "openapi" / "community.json", "enterprise": REPOSITORY_ROOT / "ee" / "openapi" / "enterprise.json", + "cloud": REPOSITORY_ROOT / "ee" / "cloud" / "openapi" / "cloud.json", } @@ -24,6 +25,10 @@ def render_openapi(profile: str) -> str: from shim_enterprise.application import create_enterprise_app application = create_enterprise_app() + elif profile == "cloud": + from shim_cloud.application import create_cloud_app + + application = create_cloud_app() else: raise ValueError(f"unknown OpenAPI profile: {profile}") return json.dumps(application.openapi(), indent=2, sort_keys=True) + "\n" diff --git a/src/shim/gateway/pipeline/admission.py b/src/shim/gateway/pipeline/admission.py index 1db91b1..5ab3c66 100644 --- a/src/shim/gateway/pipeline/admission.py +++ b/src/shim/gateway/pipeline/admission.py @@ -274,8 +274,7 @@ def candidate_count(prepared: PreparedInference) -> int: def _repeat_material(payload: Mapping[str, object]) -> str: - """Build a stable, prompt-only identity without trusted request metadata.""" - + """Hash only prompt-bearing fields so client metadata cannot mask a repeat.""" material = { key: payload[key] for key in ( @@ -284,6 +283,7 @@ def _repeat_material(payload: Mapping[str, object]) -> str: "instructions", "messages", "model", + "protocol", "provider", "system", "systemInstruction", diff --git a/tests/architecture/test_module_ownership.py b/tests/architecture/test_module_ownership.py index 4dd0f64..9b5f131 100644 --- a/tests/architecture/test_module_ownership.py +++ b/tests/architecture/test_module_ownership.py @@ -9,9 +9,12 @@ ROOT = Path(__file__).resolve().parents[2] MANIFEST = ROOT / "architecture/module_ownership.toml" -OWNERS = ("public", "enterprise", "split") +OWNERS = ("public", "enterprise", "cloud", "split") PYTHON_ROOTS = ( "ee/alembic", + "ee/cloud/alembic", + "ee/cloud/src/shim_cloud", + "ee/cloud/tests", "ee/deploy/test", "ee/scripts", "ee/src/shim_enterprise", @@ -25,6 +28,10 @@ "ee/scripts", "ee/src/shim_enterprise", ) +CLOUD_RUNTIME_ROOTS = ( + "ee/cloud/alembic", + "ee/cloud/src/shim_cloud", +) # Enterprise tests may white-box community behavior without expanding the # supported runtime API recorded in enterprise_public_api. @@ -39,15 +46,25 @@ def _forbidden_public_import_roots() -> set[str]: return set(typed_roots) -def _enterprise_public_api() -> set[tuple[str, str]]: +def _forbidden_noncloud_import_roots() -> set[str]: + document = tomllib.loads(MANIFEST.read_text(encoding="utf-8")) + roots = document.get("forbidden_noncloud_import_roots") + assert isinstance(roots, list) + assert all(isinstance(root, str) and root for root in roots) + typed_roots = [root for root in roots if isinstance(root, str)] + assert typed_roots == sorted(set(typed_roots)) + return set(typed_roots) + + +def _declared_api(section: str, root: str) -> set[tuple[str, str]]: document = tomllib.loads(MANIFEST.read_text(encoding="utf-8")) - api = document.get("enterprise_public_api") + api = document.get(section) assert isinstance(api, dict) assert list(api) == sorted(api) entries: set[tuple[str, str]] = set() for module, symbols in api.items(): - assert isinstance(module, str) and module.startswith("shim.") + assert isinstance(module, str) and module.startswith(f"{root}.") assert isinstance(symbols, list) and symbols assert all( isinstance(symbol, str) and symbol and symbol != "*" for symbol in symbols @@ -60,6 +77,18 @@ def _enterprise_public_api() -> set[tuple[str, str]]: return entries +def _enterprise_public_api() -> set[tuple[str, str]]: + return _declared_api("enterprise_public_api", "shim") + + +def _cloud_enterprise_api() -> set[tuple[str, str]]: + return _declared_api("cloud_enterprise_api", "shim_enterprise") + + +def _cloud_public_api() -> set[tuple[str, str]]: + return _declared_api("cloud_public_api", "shim") + + def _manifest_ownership() -> dict[str, str]: document = tomllib.loads(MANIFEST.read_text(encoding="utf-8")) assert document.get("version") == 1 @@ -99,6 +128,8 @@ def _module_name(path: str) -> str: parts = Path(path).with_suffix("").parts if parts[:3] == ("ee", "src", "shim_enterprise"): parts = parts[2:] + elif parts[:4] == ("ee", "cloud", "src", "shim_cloud"): + parts = parts[3:] elif parts[:2] == ("src", "shim"): parts = parts[1:] if parts[-1] == "__init__": @@ -244,10 +275,12 @@ def _scan_imports(source: str, *, package: str, known: set[str]) -> set[str]: return imported -def _scan_public_api_imports( +def _scan_api_imports( source: str, *, package: str, + root: str, + label: str, ) -> tuple[set[tuple[str, str]], set[str]]: tree = ast.parse(source) aliases = _dynamic_import_aliases(tree) @@ -257,17 +290,17 @@ def _scan_public_api_imports( for node in ast.walk(tree): if isinstance(node, ast.Import): errors.update( - f"line {node.lineno}: bare public module import {alias.name}" + f"line {node.lineno}: bare {label} module import {alias.name}" for alias in node.names - if alias.name == "shim" or alias.name.startswith("shim.") + if alias.name == root or alias.name.startswith(f"{root}.") ) elif isinstance(node, ast.ImportFrom): specifier = f"{'.' * node.level}{node.module or ''}" module = resolve_name(specifier, package) if node.level else specifier - if module == "shim" or module.startswith("shim."): + if module == root or module.startswith(f"{root}."): for alias in node.names: if alias.name == "*": - errors.add(f"line {node.lineno}: public API star import") + errors.add(f"line {node.lineno}: {label} API star import") else: imported.add((module, alias.name)) elif isinstance(node, ast.Call): @@ -280,11 +313,19 @@ def _scan_public_api_imports( continue if name.startswith(" tuple[set[tuple[str, str]], set[str]]: + return _scan_api_imports(source, package=package, root="shim", label="public") + + def _imported_module_names(path: str, known: set[str]) -> set[str]: source = ROOT / path current = _module_name(path) @@ -322,6 +363,21 @@ def _reachable_paths(graph: dict[str, set[str]], start: str) -> set[str]: return reachable +def _paths_reaching(graph: dict[str, set[str]], targets: set[str]) -> set[str]: + reverse: dict[str, set[str]] = {path: set() for path in graph} + for path, imports in graph.items(): + for imported in imports: + reverse[imported].add(path) + reachable = set(targets) + pending = list(targets) + while pending: + path = pending.pop() + parents = reverse[path] - reachable + reachable.update(parents) + pending.extend(parents) + return reachable + + def test_python_files_have_exactly_one_current_owner() -> None: _manifest_ownership() @@ -337,6 +393,11 @@ def test_python_ownership_regions_are_canonical() -> None: "ee/src/shim_enterprise/", "ee/tests/", ), + "cloud": ( + "ee/cloud/alembic/", + "ee/cloud/src/shim_cloud/", + "ee/cloud/tests/", + ), "split": ("scripts/export_openapi.py", "tests/architecture/"), } violations = { @@ -362,12 +423,41 @@ def test_public_files_cannot_reach_enterprise_files() -> None: ownership = _manifest_ownership() graph = _internal_import_graph(ownership) enterprise = {path for path, owner in ownership.items() if owner == "enterprise"} - violations = { - path: sorted(_reachable_paths(graph, path) & enterprise) + reaching_enterprise = _paths_reaching(graph, enterprise) + assert { + path for path, owner in ownership.items() - if owner == "public" - } - assert {path: imports for path, imports in violations.items() if imports} == {} + if owner == "public" and path in reaching_enterprise + } == set() + + +def test_noncloud_files_cannot_reach_cloud_files() -> None: + ownership = _manifest_ownership() + graph = _internal_import_graph(ownership) + cloud = {path for path, owner in ownership.items() if owner == "cloud"} + reaching_cloud = _paths_reaching(graph, cloud) + assert { + path + for path, owner in ownership.items() + if owner in {"public", "enterprise"} and path in reaching_cloud + } == set() + + +def test_noncloud_files_cannot_import_cloud_only_dependencies() -> None: + ownership = _manifest_ownership() + forbidden = _forbidden_noncloud_import_roots() + violations: dict[str, list[str]] = {} + for path, owner in ownership.items(): + if owner not in {"public", "enterprise"}: + continue + imports = { + name + for name in _imported_module_names(path, forbidden) + if name in forbidden + } + if imports: + violations[path] = sorted(imports) + assert violations == {} def test_public_files_cannot_reach_forbidden_or_unresolved_dependencies() -> None: @@ -399,27 +489,76 @@ def test_enterprise_runtime_uses_exact_declared_public_api() -> None: allowed = _enterprise_public_api() assert {module for module, _ in allowed} <= public_modules + imported, errors = _runtime_api_imports( + ENTERPRISE_RUNTIME_ROOTS, root="shim", label="public" + ) + + assert errors == {} + assert imported == allowed, ( + f"undeclared public API: {sorted(imported - allowed)}; " + f"stale public API: {sorted(allowed - imported)}" + ) + + +def _runtime_api_imports( + directories: tuple[str, ...], *, root: str, label: str +) -> tuple[set[tuple[str, str]], dict[str, list[str]]]: imported: set[tuple[str, str]] = set() errors: dict[str, list[str]] = {} - for directory in ENTERPRISE_RUNTIME_ROOTS: + for directory in directories: for path in sorted((ROOT / directory).rglob("*.py")): relative_path = path.relative_to(ROOT).as_posix() module = _module_name(relative_path) package = ( module if path.name == "__init__.py" else module.rpartition(".")[0] ) - file_imports, file_errors = _scan_public_api_imports( + file_imports, file_errors = _scan_api_imports( path.read_text(encoding="utf-8"), package=package, + root=root, + label=label, ) imported.update(file_imports) if file_errors: errors[relative_path] = sorted(file_errors) + return imported, errors - assert errors == {} - assert imported == allowed, ( - f"undeclared public API: {sorted(imported - allowed)}; " - f"stale public API: {sorted(allowed - imported)}" + +def test_cloud_runtime_uses_exact_declared_shared_apis() -> None: + ownership = _manifest_ownership() + public_modules = { + _module_name(path) + for path, owner in ownership.items() + if owner == "public" and path.startswith("src/shim/") + } + enterprise_modules = { + _module_name(path) + for path, owner in ownership.items() + if owner == "enterprise" and path.startswith("ee/src/shim_enterprise/") + } + allowed_public = _cloud_public_api() + allowed_enterprise = _cloud_enterprise_api() + assert {module for module, _ in allowed_public} <= public_modules + assert {module for module, _ in allowed_enterprise} <= enterprise_modules + + public_imports, public_errors = _runtime_api_imports( + CLOUD_RUNTIME_ROOTS, root="shim", label="public" + ) + enterprise_imports, enterprise_errors = _runtime_api_imports( + CLOUD_RUNTIME_ROOTS, root="shim_enterprise", label="enterprise" + ) + + assert public_errors == {} + assert enterprise_errors == {} + assert public_imports == allowed_public, ( + f"undeclared cloud public API: {sorted(public_imports - allowed_public)}; " + f"stale cloud public API: {sorted(allowed_public - public_imports)}" + ) + assert enterprise_imports == allowed_enterprise, ( + "undeclared cloud enterprise API: " + f"{sorted(enterprise_imports - allowed_enterprise)}; " + "stale cloud enterprise API: " + f"{sorted(allowed_enterprise - enterprise_imports)}" ) diff --git a/tests/architecture/test_release_workflow.py b/tests/architecture/test_release_workflow.py index 342a90d..88aaa05 100644 --- a/tests/architecture/test_release_workflow.py +++ b/tests/architecture/test_release_workflow.py @@ -10,6 +10,7 @@ ROOT = Path(__file__).resolve().parents[2] WORKFLOWS = ROOT / ".github" / "workflows" RELEASE = WORKFLOWS / "release.yml" +ENTERPRISE_RELEASE = WORKFLOWS / "enterprise-release.yml" IMAGE = "ghcr.io/getshim/shim" DEPLOYMENT_COMMANDS = ("gcloud run", "gcloud builds", "update-traffic", "kubectl") RELEASE_TEXT = RELEASE.read_text() @@ -28,6 +29,17 @@ def test_release_publishes_the_community_image_with_an_sbom() -> None: assert "attest-build-provenance" in RELEASE_TEXT assert "linux/amd64,linux/arm64" in RELEASE_TEXT assert "ee/Dockerfile" not in RELEASE_TEXT + assert "ee/cloud" not in RELEASE_TEXT + assert "shim-cloud" not in RELEASE_TEXT + + +def test_enterprise_release_excludes_the_cloud_composition() -> None: + release_text = ENTERPRISE_RELEASE.read_text() + + assert "file: ee/Dockerfile" in release_text + assert "ee/cloud" not in release_text + assert "shim-cloud" not in release_text + assert "uv build" not in release_text def test_release_never_deploys() -> None: diff --git a/tests/architecture/test_repository_manifest.py b/tests/architecture/test_repository_manifest.py index a3f5706..9bd9a86 100644 --- a/tests/architecture/test_repository_manifest.py +++ b/tests/architecture/test_repository_manifest.py @@ -497,6 +497,7 @@ def test_docker_build_contexts_are_exactly_allowlisted() -> None: "!src/**", "!ee/", "!ee/pyproject.toml", + "!ee/cloud/pyproject.toml", "**/__pycache__/", "**/*.py[cod]", ), @@ -513,6 +514,7 @@ def test_docker_build_contexts_are_exactly_allowlisted() -> None: "!ee/LICENSE", "!ee/NOTICE", "!ee/pyproject.toml", + "!ee/cloud/pyproject.toml", "!ee/src/", "!ee/src/**", "!ee/alembic.ini", @@ -523,6 +525,36 @@ def test_docker_build_contexts_are_exactly_allowlisted() -> None: "**/__pycache__/", "**/*.py[cod]", ), + "ee/cloud/Dockerfile.dockerignore": ( + "**", + "!README.md", + "!LICENSE", + "!NOTICE", + "!pyproject.toml", + "!uv.lock", + "!src/", + "!src/**", + "!ee/", + "!ee/LICENSE", + "!ee/NOTICE", + "!ee/pyproject.toml", + "!ee/src/", + "!ee/src/**", + "!ee/alembic.ini", + "!ee/alembic/", + "!ee/alembic/**", + "!ee/cloud/", + "!ee/cloud/LICENSE", + "!ee/cloud/NOTICE", + "!ee/cloud/pyproject.toml", + "!ee/cloud/src/", + "!ee/cloud/src/**", + "!ee/cloud/alembic.ini", + "!ee/cloud/alembic/", + "!ee/cloud/alembic/**", + "**/__pycache__/", + "**/*.py[cod]", + ), } for path, lines in expected.items(): @@ -560,9 +592,10 @@ def test_cloud_build_deploys_migrations_and_standalone_workers() -> None: for argument in steps["deploy-gateway"]["args"] ) assert "--revision-suffix=rel-$SHORT_SHA" in steps["deploy-gateway"]["args"] - assert "--args=-c,ee/alembic.ini,upgrade,head" in steps["deploy-migration"]["args"] + assert "--command=python" in steps["deploy-migration"]["args"] + assert "--args=-m,shim_cloud.migrate" in steps["deploy-migration"]["args"] expected_workers = { - "deploy-outbox-worker": "--args=-m,shim_enterprise.workers.outbox", + "deploy-outbox-worker": "--args=-m,shim_cloud.worker", "deploy-reconciliation-worker": ( "--args=-m,shim_enterprise.workers.reconciliation" ), @@ -788,6 +821,7 @@ def test_cloud_build_deploys_migrations_and_standalone_workers() -> None: "${_REGION}-docker.pkg.dev/$PROJECT_ID/" "${_ARTIFACT_REPOSITORY}/${_IMAGE_NAME}:$COMMIT_SHA" ) + assert "--dockerfile=ee/cloud/Dockerfile" in steps["build-image"]["args"] assert f"--destination={image_ref}" in steps["build-image"]["args"] for step_id in ("deploy-migration", "deploy-gateway", *expected_workers): assert f"--image={image_ref}" in steps[step_id]["args"] diff --git a/tests/architecture/test_route_profiles.py b/tests/architecture/test_route_profiles.py index 9e43445..f3e04cb 100644 --- a/tests/architecture/test_route_profiles.py +++ b/tests/architecture/test_route_profiles.py @@ -11,10 +11,11 @@ ROOT = Path(__file__).resolve().parents[2] MANIFEST = ROOT / "architecture/route_profiles.toml" -PROFILE_NAMES = ("community", "enterprise") +PROFILE_NAMES = ("community", "enterprise", "cloud") PROFILE_SCHEMAS = { "community": ROOT / "openapi/community.json", "enterprise": ROOT / "ee/openapi/enterprise.json", + "cloud": ROOT / "ee/cloud/openapi/cloud.json", } OPENAPI_METHODS = frozenset( {"delete", "get", "head", "options", "patch", "post", "put", "trace"} @@ -77,10 +78,18 @@ def test_route_profiles_are_normalized_and_unique() -> None: _route_profiles() +@pytest.mark.parametrize("profile", PROFILE_NAMES) +def test_profile_routes_are_sorted(profile: str) -> None: + routes = _route_profiles()[profile] + + assert routes == sorted(routes), f"{profile} profile routes are not sorted" + + def test_community_profile_is_a_strict_enterprise_subset() -> None: profiles = _route_profiles() assert set(profiles["community"]) < set(profiles["enterprise"]) + assert set(profiles["enterprise"]) < set(profiles["cloud"]) @pytest.mark.parametrize("profile", PROFILE_NAMES) diff --git a/tests/gateway/test_token_count.py b/tests/gateway/test_token_count.py index b1153e8..74743a8 100644 --- a/tests/gateway/test_token_count.py +++ b/tests/gateway/test_token_count.py @@ -166,11 +166,10 @@ def upstream(request): "messages": [{"role": "user", "content": "hello"}], } counted = await inbound.post("/v1/messages/count_tokens", json=payload) - generated = await inbound.post( - "/v1/messages", json={**payload, "max_tokens": 10} - ) - repeated = await inbound.post( - "/v1/messages", json={**payload, "max_tokens": 10} - ) + completions = {**payload, "max_tokens": 10} + generated = await inbound.post("/v1/messages", json=completions) + repeated = await inbound.post("/v1/messages", json=completions) + blocked = await inbound.post("/v1/messages", json=completions) assert counted.status_code == generated.status_code == 200 - assert repeated.status_code == 429 + assert repeated.status_code == 200 + assert blocked.status_code == 429 diff --git a/uv.lock b/uv.lock index 364c0f0..ac718ec 100644 --- a/uv.lock +++ b/uv.lock @@ -4,6 +4,7 @@ requires-python = "==3.13.*" [manifest] members = [ + "shim-cloud", "shim-enterprise", "shim-gateway", ] @@ -87,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "authlib" version = "1.8.0" @@ -354,6 +364,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/3f/35701c13e1fc7b0895198c8b20068c569a841e0daf8e0b14d1dc0816b28f/cymem-2.0.13-cp313-cp313t-win_arm64.whl", hash = "sha256:042e8611ef862c34a97b13241f5d0da86d58aca3cecc45c533496678e75c5a1f", size = 38964, upload-time = "2025-11-14T14:58:02.87Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "deprecation" version = "2.1.0" @@ -772,6 +794,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" }, ] +[[package]] +name = "jsonpath-python" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/18/4ca8742534a5993ff383f7602e325ce2d5d7cc93d72ac5e1cdedbea8a458/jsonpath_python-1.1.6.tar.gz", hash = "sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671", size = 88178, upload-time = "2026-05-07T01:26:34.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" }, +] + [[package]] name = "mako" version = "1.3.12" @@ -1106,6 +1137,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polar-sdk" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore" }, + { name = "httpx" }, + { name = "jsonpath-python" }, + { name = "pydantic" }, + { name = "standardwebhooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/d3/23a2aa90331a3788a965d470ae788022ca1e4b4ea5bee22fcccdce55bbc6/polar_sdk-0.32.0.tar.gz", hash = "sha256:2be16af1070d4dedd32db296e88596ead3264df6d61d5a9ef5c5c4b827c4ce72", size = 326393, upload-time = "2026-07-20T08:12:49.094Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/cb/b5b1bd64bf9ec1f41665bc6edfb9b115c0943a11b0ce54b740cc0f738988/polar_sdk-0.32.0-py3-none-any.whl", hash = "sha256:cf75825c944820a9f90d93eecae0fb5fbb2a29110bc34ae9aa4e93dc250f489b", size = 909811, upload-time = "2026-07-20T08:12:47.726Z" }, +] + [[package]] name = "postgrest" version = "2.31.0" @@ -1607,6 +1654,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "shim-cloud" +version = "0.1.3" +source = { editable = "ee/cloud" } +dependencies = [ + { name = "polar-sdk" }, + { name = "shim-enterprise" }, + { name = "standardwebhooks" }, +] + +[package.metadata] +requires-dist = [ + { name = "polar-sdk", specifier = "==0.32.0" }, + { name = "shim-enterprise", specifier = "==0.1.3" }, + { name = "standardwebhooks", specifier = "==1.0.0" }, +] + [[package]] name = "shim-enterprise" version = "0.1.3" @@ -1859,6 +1923,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/8a/62fb7a971eca29e12f03fb9ddacb058548c14d33e5b5675ff0f85839cc7b/srsly-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:0f106b0a700ab56e4a7c431b0f1444009ab6cb332edc7bbf6811c2a43f4722cb", size = 637278, upload-time = "2026-03-23T11:56:21.439Z" }, ] +[[package]] +name = "standardwebhooks" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "deprecated" }, + { name = "httpx" }, + { name = "python-dateutil" }, + { name = "types-deprecated" }, + { name = "types-python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/c8/4c9705c0499c8b5ac54a082539d4cdf1c6b3977b7475d60fcb7c666efc86/standardwebhooks-1.0.0.tar.gz", hash = "sha256:d94b99c0dcea84156e03adad94f8dba32d5454cc68e12ec2c824051b55bb67ff", size = 4919, upload-time = "2024-01-12T14:57:43.193Z" } + [[package]] name = "starlette" version = "1.6.0" @@ -2047,6 +2125,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] +[[package]] +name = "types-deprecated" +version = "1.3.1.20260728" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/0f/a6c55e899f6a2b3519f4079ee1d99f64411299a6d882decbdf17b17319ba/types_deprecated-1.3.1.20260728.tar.gz", hash = "sha256:cab836fdcf6b57d3e277e62d9d76d8a4c556a2639fd0e90cb555fa0e8300d164", size = 8729, upload-time = "2026-07-28T04:51:30.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/e6/4e4d2a7d861ac277fbdce3d9a25ce5205f366bcc4e37c021eeb2017c160c/types_deprecated-1.3.1.20260728-py3-none-any.whl", hash = "sha256:d94b3e1db08db6a63ad0e8519e2da2b2d22843f015efa7bd83baef6aa99f7403", size = 9109, upload-time = "2026-07-28T04:51:30.008Z" }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260807" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/4e/b3fa538f9cb38dfece0d6ccf6d3d0d925bdedb144fb9c8129dfc007cd003/types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034", size = 17618, upload-time = "2026-08-07T04:17:13.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/5e/3715867caea2f4cea56ccb04c851cde23ed063449c3b004c7a047f20dd48/types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db", size = 18486, upload-time = "2026-08-07T04:17:12.504Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"