diff --git a/.fernignore b/.fernignore index 8718b2d8..a8c9d9fa 100644 --- a/.fernignore +++ b/.fernignore @@ -62,3 +62,15 @@ src/smallestai/waves/helpers/** # Hand-written README (do not regenerate) README.md + + +# Prebuilt tools framework (hand-written) +src/smallestai/tools/** + +# Anonymous opt-out telemetry (hand-written) +src/smallestai/telemetry.py + +# Dev quality/security tooling (hand-written) +.pre-commit-config.yaml +.gitleaks.toml +.deepsource.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90d34127..6e98da77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,49 @@ jobs: - name: Test run: poetry run pytest -rP -n auto . + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install ruff + run: pip install ruff==0.16.1 + - name: Ruff lint + run: ruff check . + - name: Ruff format check + run: ruff format --check . + + security: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Secret scan (gitleaks) + run: | + curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar -xz gitleaks + ./gitleaks dir . --config .gitleaks.toml --redact --no-banner + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Dependency audit (pip-audit) + # Report-only for now: the pinned transitive deps (pytest, requests, urllib3, + # starlette, setuptools, python-dotenv) carry a pre-existing advisory backlog to + # clear in a dependency-bump PR. Flip to blocking (remove continue-on-error) after. + continue-on-error: true + run: | + poetry run pip install pip-audit + poetry run pip-audit + # Auto-publish to PyPI when the version in pyproject.toml has been bumped # beyond the latest git tag. Only fires on pushes to main, only after # compile+test pass. No-ops on pushes that didn't change the version. @@ -57,7 +100,7 @@ jobs: # Required repo secret: PYPI_API_TOKEN (a PyPI API token with upload scope # for the `smallestai` project). publish: - needs: [compile, test] + needs: [compile, test, lint, security] if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest permissions: diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..6a65530e --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,17 @@ +# gitleaks config (used by the pre-commit hook and the CI security job). +# Extends the built-in ruleset and allowlists the PostHog project key, which is a +# write-only client key that is meant to ship in client code. +[extend] +useDefault = true + +[allowlist] +description = "PostHog public write-only project key, and generated wire-test fixtures" +regexes = [ + '''phc_[A-Za-z0-9]{40,}''', +] +# wiremock/wiremock-mappings.json is a generated wire-test fixture. Its stub +# responses contain example (non-real) LiveKit JWT tokens for the web-call +# endpoints, which gitleaks flags as JWTs. It is test data, not source secrets. +paths = [ + '''wiremock/wiremock-mappings\.json''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1d7d5a1c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +# Local quality + security gates. Install once with `pre-commit install`, +# then hooks run on every commit. Check everything with `pre-commit run --all-files`. +# Keep the ruff rev in sync with the `lint` job in .github/workflows/ci.yml. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.1 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks diff --git a/README.md b/README.md index ece71c61..4eff40e0 100644 --- a/README.md +++ b/README.md @@ -81,13 +81,14 @@ endpoint (a hosted API, or a local model via Ollama): from smallestai.atoms.crew.nodes import OutputCrewNode from smallestai.atoms.crew.clients.openai import OpenAIClient + class Assistant(OutputCrewNode): def __init__(self): super().__init__(name="assistant") self.llm = OpenAIClient( model="claude-haiku-4-5", api_key="", - base_url="https://api.anthropic.com/v1/", # or http://localhost:11434/v1 for Ollama + base_url="https://api.anthropic.com/v1/", # or http://localhost:11434/v1 for Ollama ) async def generate_response(self): @@ -129,11 +130,13 @@ The SDK exports an `async` client with the same surface: import asyncio from smallestai import AsyncSmallestAI + async def main(): client = AsyncSmallestAI(api_key="") agents = await client.atoms.agents.list_agents() print(agents.data) + asyncio.run(main()) ``` @@ -182,9 +185,9 @@ Use `.with_raw_response` to get the response headers and status alongside the pa ```python response = client.atoms.agents.with_raw_response.get_agent(id="") -print(response.headers) # response headers -print(response.status_code) # status code -print(response.data) # parsed object +print(response.headers) # response headers +print(response.status_code) # status code +print(response.data) # parsed object ``` ### Retries @@ -231,6 +234,20 @@ client = SmallestAI( - Full API reference: [reference.md](./reference.md) - Product docs: https://smallest.ai/docs +## Telemetry + +The SDK sends anonymous, aggregated usage telemetry (which CLI commands run, deploy +outcomes) so we can see what to improve. It never includes personal data or secrets: +no API keys, agent ids, prompts, transcripts, phone numbers, file paths, or error +messages. Only the event name, SDK / Python / OS version, and a random anonymous +install id. It is fire-and-forget and never blocks your program. + +Opt out any time: + +```bash +export SMALLESTAI_TELEMETRY=0 # or DO_NOT_TRACK=1 +``` + ## Contributing Most of `src/` is generated from an API spec and gets overwritten on regeneration, diff --git a/changelog.md b/changelog.md index 0feb8ae3..7644a529 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,43 @@ +## 5.10.0 - 2026-08-07 + +* **tools**: new `smallestai.tools` framework for prebuilt, pluggable crew tools. Each tool + plugs into a crew's `ToolRegistry` and is also callable directly; third-party libraries + install as optional extras, lazy-imported. First tool: `ExaSearchTool` (web search; + `pip install "smallestai[exa]"`, reads `EXA_API_KEY`). +* **cli**: new `smallestai agent-crew doctor [--agent-id]` inspects a crew agent and flags + common gotchas (no live build, wrong workflow type, PII redaction on, `transfer_call` + toggled in the dashboard). `agent-crew init` now prints the crew-vs-platform config + ownership boundary. +* **cli**: new `smallestai mcp` command to set up or run the Smallest AI MCP server + (`@developer-smallestai/smallest-mcp-server`) for Cursor / Claude. `mcp` prints the config + + `claude mcp add` line, `mcp run` launches it via npx, `mcp config` prints the mcp.json. +* **cli**: running bare `smallestai` now shows a banner + command list instead of a + "Missing command" error. +* **telemetry**: anonymous, opt-out usage telemetry (PostHog). No personal data or secrets: + only the event name, SDK/Python/OS version, and a random anonymous install id. + Fire-and-forget, never blocks. Opt out with `SMALLESTAI_TELEMETRY=0` (or `DO_NOT_TRACK=1`). +* **crew**: `SDKSystemUpdateOutputAgentSettingsEvent` is deprecated - the platform does not + apply crew-sent output-agent settings. It will be removed in a future release. +* **dev**: pre-commit hooks (ruff lint + format, gitleaks) and CI quality/security jobs - + `ruff check` / `ruff format --check` and a gitleaks secret scan gate publishing, plus + `pip-audit` dependency-CVE reporting (report-only for now). +* **api (Voice Agents)**: new endpoints on the client: + * `client.atoms.user.get_subscription()` - plan id, credit balance, per-plan limits, feature flags. + * `client.atoms.account.get_account_details()` - profile plus the orgs the user belongs to. + * `client.atoms.account.update_organization_name(...)` - rename the active org (owner role). + * `client.atoms.web_call.start_web_chat_conversation(...)` / `start_web_call_conversation(...)` - mint a LiveKit token + room for a browser text/voice session. + * `client.atoms.campaigns.export_campaign_logs(...)` - campaign call logs. + * `client.atoms.campaigns.export_campaign_results_by_audience_member(...)` - results grouped by contact (`format=json|csv`). +* **api (Speech)**: new endpoints on the client: + * `client.waves.post_call_analysis.analyze(...)` - disposition metrics from a transcript. + * `client.waves.post_call_analysis.generate(...)` - single-prompt text generation. + * `client.waves.voices.get_all_voice_models()` - the full voice catalog. + * `client.waves.analytics.*` - ASR/TTS logs and usage/credits/concurrency timeseries, webhook logs. + * `client.waves.ops.get_waves_health()` - service health. +* **api (removed)**: `client.atoms.organization` is removed. Its endpoint (`GET /organization`) + returned 404 on the public API and was never functional; use + `client.atoms.account.get_account_details()` / `client.atoms.user.get_user_details()` instead. + ## 5.5.0 - 2026-08-05 DevX pass (backward-compatible). diff --git a/docs/getting-started.md b/docs/getting-started.md index c362ef08..16bef9bc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -25,10 +25,10 @@ The CLI (`smallestai`) reads `SMALLEST_API_KEY`, or the key stored by ```python agent_id = client.atoms.agents.create_agent( name="Front desk", - workflow_type="single_prompt", # the default; the other type is workflow_graph + workflow_type="single_prompt", # the default; the other type is workflow_graph first_message="Hi, thanks for calling. How can I help?", ).data -print(agent_id) # NOTE: .data here is the agent id string, not an object +print(agent_id) # NOTE: .data here is the agent id string, not an object ``` Two things worth knowing up front: @@ -43,7 +43,7 @@ Two things worth knowing up front: client.atoms.calls.start_outbound_call( agent_id=agent_id, phone_number="+15559990000", - from_product_id="", # a number you own; see client.atoms.phone_numbers.list() + from_product_id="", # a number you own; see client.atoms.phone_numbers.list() ) ``` diff --git a/docs/guides/crew.md b/docs/guides/crew.md index 492e4f6d..9e1f6eaf 100644 --- a/docs/guides/crew.md +++ b/docs/guides/crew.md @@ -9,6 +9,7 @@ endpoint, e.g. Claude via Anthropic) and full control over the turn. You subclas ```python from smallestai.atoms.crew.nodes import OutputCrewNode + class Assistant(OutputCrewNode): def __init__(self): super().__init__(name="assistant") diff --git a/docs/guides/transfer-call.md b/docs/guides/transfer-call.md index 5c4a2cb6..c660f085 100644 --- a/docs/guides/transfer-call.md +++ b/docs/guides/transfer-call.md @@ -45,13 +45,13 @@ the next call. ```python from smallestai.atoms.helpers import AgentTools -tools = AgentTools(api_key="sk_...") # or SMALLEST_API_KEY env var +tools = AgentTools(api_key="sk_...") # or SMALLEST_API_KEY env var tools.add_transfer_call( "AGENT_ID", number="+15551234567", - transfer_type="cold_transfer", # or "warm_transfer" - on_hold_music="relaxing_sound", # audio while bridging + transfer_type="cold_transfer", # or "warm_transfer" + on_hold_music="relaxing_sound", # audio while bridging ) ``` @@ -97,13 +97,14 @@ from smallestai.atoms.crew.events import ( TransferOptionType, ) + @function_tool(name="transfer_call") async def transfer_call(self) -> None: await self.send_event( SDKAgentTransferConversationEvent( transfer_call_number="+15551234567", transfer_options=TransferOption(type=TransferOptionType.COLD_TRANSFER), - on_hold_music="relaxing_sound", # optional; omit for platform default + on_hold_music="relaxing_sound", # optional; omit for platform default ) ) ``` diff --git a/examples/agent_versioning_lifecycle.py b/examples/agent_versioning_lifecycle.py index 0d04c33b..02cf444e 100644 --- a/examples/agent_versioning_lifecycle.py +++ b/examples/agent_versioning_lifecycle.py @@ -13,10 +13,11 @@ export SMALLEST_API_KEY=sk_... python examples/agent_versioning_lifecycle.py """ + import os from smallestai import SmallestAI -from smallestai.atoms.helpers.versioning import Versioning, DraftConflictError +from smallestai.atoms.helpers.versioning import DraftConflictError, Versioning client = SmallestAI(api_key=os.environ["SMALLEST_API_KEY"]) @@ -44,7 +45,8 @@ # Quick edit: change the config and publish in one call (draft -> publish -> live, # security scan handled). This is the simple "update my agent" path. revision = v.edit_and_publish( - agent_id, main_id, + agent_id, + main_id, global_prompt="You are a warm, concise receptionist. Confirm details before booking.", label="tone tweak", ) @@ -60,7 +62,7 @@ # Fork Main so real traffic keeps hitting the live config while you experiment. staging_id = v.branches.create_branch(id=agent_id, source_branch_id=main_id, name="staging").data.id v.edit_and_publish(agent_id, staging_id, global_prompt="New experimental prompt.", label="experiment") -v.branches.make_live(id=agent_id, branch_id=staging_id) # staging live, Main flips to not-live +v.branches.make_live(id=agent_id, branch_id=staging_id) # staging live, Main flips to not-live # Roll back: restore an older revision as a new head revision on the live branch. older = v.revisions.list(id=agent_id, branch_id=staging_id).data.revisions[-1].id @@ -72,8 +74,9 @@ draft = v.branches.get_draft(id=agent_id, branch_id=main_id).data try: v.edit_and_publish( - agent_id, main_id, - expected_revision=draft.latest.draft_revision, # only apply if nobody edited since + agent_id, + main_id, + expected_revision=draft.latest.draft_revision, # only apply if nobody edited since global_prompt="Applied only if no conflicting edit landed first.", ) except DraftConflictError as e: diff --git a/examples/build_voice_agent.py b/examples/build_voice_agent.py index 3e0b18ba..aa1b1830 100644 --- a/examples/build_voice_agent.py +++ b/examples/build_voice_agent.py @@ -17,12 +17,13 @@ see the cookbook / `smallestai agent-crew deploy`. This script proves the create-configure-publish-activate path that every voice agent starts from. """ + import os import time from smallestai import SmallestAI -from smallestai.environment import SmallestAIEnvironment from smallestai.atoms.helpers import as_page +from smallestai.environment import SmallestAIEnvironment def _id_of(obj): @@ -70,9 +71,7 @@ def main() -> None: print("4. (optional) knowledge base for the menu") created_kb = None try: - kb = c.atoms.knowledge_base.create( - name="mario-menu", description="Pizza menu + prices" - ) + kb = c.atoms.knowledge_base.create(name="mario-menu", description="Pizza menu + prices") created_kb = getattr(kb, "data", None) print(" KB created:", created_kb) except Exception as e: diff --git a/examples/crew_transfer_node.py b/examples/crew_transfer_node.py index 61feb1b0..ad827261 100644 --- a/examples/crew_transfer_node.py +++ b/examples/crew_transfer_node.py @@ -17,14 +17,15 @@ requirements.txt pinning smallestai>=5.4.0 plus your LLM client): smallestai agent-crew deploy --entry-point server.py """ + import os -from smallestai.atoms.crew.nodes import OutputCrewNode from smallestai.atoms.crew.events import ( SDKAgentTransferConversationEvent, TransferOption, TransferOptionType, ) +from smallestai.atoms.crew.nodes import OutputCrewNode # Bring your own OpenAI-compatible client + tool registry. This example assumes a # client exposing `.chat(messages=..., stream=True, tools=...)` and a registry that @@ -41,6 +42,7 @@ def _decorator(fn): return _decorator + TRANSFER_NUMBER = os.getenv("TRANSFER_CALL_NUMBER", "+15551234567") @@ -82,9 +84,7 @@ async def transfer_call(self) -> None: async def generate_response(self): # self.context.messages is seeded from the platform's authoritative # messages before this runs (5.4.0), so it always has the latest user turn. - response = await self.llm.chat( - messages=self.context.messages, stream=True, tools=self.tool_schemas - ) + response = await self.llm.chat(messages=self.context.messages, stream=True, tools=self.tool_schemas) full = "" tool_calls = [] async for chunk in response: diff --git a/examples/inspect_calls.py b/examples/inspect_calls.py index c5712f58..15ca01c8 100644 --- a/examples/inspect_calls.py +++ b/examples/inspect_calls.py @@ -11,6 +11,7 @@ python examples/inspect_calls.py # lists recent calls python examples/inspect_calls.py CALL-... # details + transcript for one call """ + import os import sys diff --git a/examples/transfer_call.py b/examples/transfer_call.py index 1c6fa723..aee72311 100644 --- a/examples/transfer_call.py +++ b/examples/transfer_call.py @@ -19,6 +19,7 @@ export SMALLEST_API_KEY=sk_... python examples/transfer_call.py """ + import os from smallestai import SmallestAI diff --git a/poetry.lock b/poetry.lock index 88889d05..b5fa9098 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "annotated-doc" @@ -6,7 +6,6 @@ version = "0.0.4" description = "Document parameters, class attributes, return types, and variables inline, with Annotated." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, @@ -18,7 +17,6 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -30,7 +28,6 @@ version = "4.5.2" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, @@ -44,7 +41,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] trio = ["trio (>=0.26.1)"] [[package]] @@ -53,7 +50,6 @@ version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, @@ -65,7 +61,6 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -204,7 +199,6 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -213,18 +207,30 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click" +version = "8.4.2" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +files = [ + {file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"}, + {file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "distro" @@ -232,20 +238,37 @@ version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "exa-py" +version = "2.16.2" +description = "Python SDK for Exa API." +optional = true +python-versions = ">=3.9" +files = [ + {file = "exa_py-2.16.2-py3-none-any.whl", hash = "sha256:51285f76c82c62e91c59147c49e1b3c121f9431c8e1ea99d7465374dbed131e8"}, + {file = "exa_py-2.16.2.tar.gz", hash = "sha256:e2eed571874edabe2276cee8a66640ef9ce50301e0a66f58383a570864b9d5ea"}, +] + +[package.dependencies] +httpcore = ">=1.0.9" +httpx = ">=0.28.1" +openai = ">=1.48" +pydantic = ">=2.10.6" +python-dotenv = ">=1.0.1" +requests = ">=2.32.3" +typing-extensions = ">=4.12.2" + [[package]] name = "exceptiongroup" version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, @@ -263,7 +286,6 @@ version = "2.1.2" description = "execnet: rapid multi-Python deployment" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, @@ -278,7 +300,6 @@ version = "0.124.4" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "fastapi-0.124.4-py3-none-any.whl", hash = "sha256:6d1e703698443ccb89e50abe4893f3c84d9d6689c0cf1ca4fad6d3c15cf69f15"}, {file = "fastapi-0.124.4.tar.gz", hash = "sha256:0e9422e8d6b797515f33f500309f6e1c98ee4e85563ba0f2debb282df6343763"}, @@ -301,7 +322,6 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -313,7 +333,6 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -335,7 +354,6 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -348,7 +366,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -360,7 +378,6 @@ version = "3.15" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, @@ -375,7 +392,6 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -387,7 +403,6 @@ version = "0.9.1" description = "Fast iterable JSON parser." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "jiter-0.9.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c0163baa7ee85860fdc14cc39263014500df901eeffdf94c1eab9a2d713b2a9d"}, {file = "jiter-0.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:514d4dd845e0af4da15112502e6fcb952f0721f27f17e530454e379472b90c14"}, @@ -473,7 +488,6 @@ version = "0.7.3" description = "Python logging made (stupidly) simple" optional = false python-versions = "<4.0,>=3.5" -groups = ["main"] files = [ {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, @@ -484,7 +498,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] +dev = ["Sphinx (==8.1.3)", "build (==1.2.2)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.5.0)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.13.0)", "mypy (==v1.4.1)", "myst-parser (==4.0.0)", "pre-commit (==4.0.1)", "pytest (==6.1.2)", "pytest (==8.3.2)", "pytest-cov (==2.12.1)", "pytest-cov (==5.0.0)", "pytest-cov (==6.0.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.1.0)", "sphinx-rtd-theme (==3.0.2)", "tox (==3.27.1)", "tox (==4.23.2)", "twine (==6.0.1)"] [[package]] name = "markdown-it-py" @@ -492,8 +506,6 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.9\"" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -512,38 +524,12 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] -[[package]] -name = "markdown-it-py" -version = "4.2.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, - {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] - [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -555,7 +541,6 @@ version = "1.13.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, @@ -609,7 +594,6 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -621,7 +605,6 @@ version = "2.2.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "openai-2.2.0-py3-none-any.whl", hash = "sha256:d222e63436e33f3134a3d7ce490dc2d2f146fa98036eb65cc225df3ce163916f"}, {file = "openai-2.2.0.tar.gz", hash = "sha256:bc49d077a8bf0e370eec4d038bc05e232c20855a19df0b58e5b3e5a8da7d33e0"}, @@ -649,7 +632,6 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, @@ -661,7 +643,6 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -677,7 +658,6 @@ version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, @@ -692,7 +672,6 @@ version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, @@ -705,7 +684,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +timezone = ["tzdata"] [[package]] name = "pydantic-core" @@ -713,7 +692,6 @@ version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, @@ -826,7 +804,6 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -841,7 +818,6 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -864,7 +840,6 @@ version = "0.23.8" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, @@ -883,7 +858,6 @@ version = "3.6.1" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, @@ -904,7 +878,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -919,7 +892,6 @@ version = "1.0.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, @@ -934,7 +906,6 @@ version = "2.1.1" description = "Python library to build pretty command line user prompts ⭐️" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59"}, {file = "questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d"}, @@ -949,7 +920,6 @@ version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, @@ -971,7 +941,6 @@ version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.9.0" -groups = ["main"] files = [ {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, @@ -990,7 +959,6 @@ version = "0.11.5" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b"}, {file = "ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077"}, @@ -1018,7 +986,6 @@ version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -1030,7 +997,6 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -1042,7 +1008,6 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1054,7 +1019,6 @@ version = "0.44.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "starlette-0.44.0-py3-none-any.whl", hash = "sha256:19edeb75844c16dcd4f9dd72f22f9108c1539f3fc9c4c88885654fef64f85aea"}, {file = "starlette-0.44.0.tar.gz", hash = "sha256:e35166950a3ccccc701962fe0711db0bc14f2ecd37c6f9fe5e3eae0cbaea8715"}, @@ -1073,7 +1037,6 @@ version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, @@ -1123,7 +1086,6 @@ files = [ {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] -markers = {dev = "python_version < \"3.11\""} [[package]] name = "tomli-w" @@ -1131,7 +1093,6 @@ version = "1.2.0" description = "A lil' TOML writer" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, @@ -1143,7 +1104,6 @@ version = "4.67.3" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf"}, {file = "tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb"}, @@ -1159,33 +1119,12 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] -[[package]] -name = "typer" -version = "0.23.1" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e"}, - {file = "typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134"}, -] - -[package.dependencies] -annotated-doc = ">=0.0.2" -click = ">=8.0.0" -rich = ">=10.11.0" -shellingham = ">=1.3.0" - [[package]] name = "typer" version = "0.23.2" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.9\"" files = [ {file = "typer-0.23.2-py3-none-any.whl", hash = "sha256:e9c8dc380f82450b3c851a9b9d5a0edf95d1d6456ae70c517d8b06a50c7a9978"}, {file = "typer-0.23.2.tar.gz", hash = "sha256:a99706a08e54f1aef8bb6a8611503808188a4092808e86addff1828a208af0de"}, @@ -1193,7 +1132,10 @@ files = [ [package.dependencies] annotated-doc = ">=0.0.2" -click = {version = ">=8.0.0", markers = "python_version < \"3.10\""} +click = [ + {version = ">=8.0.0", markers = "python_version < \"3.10\""}, + {version = ">=8.2.1", markers = "python_version >= \"3.10\""}, +] rich = ">=12.3.0" shellingham = ">=1.3.0" @@ -1203,7 +1145,6 @@ version = "2.9.0.20241206" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, @@ -1215,7 +1156,6 @@ version = "2.32.0.20241016" description = "Typing stubs for requests" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, @@ -1230,7 +1170,6 @@ version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, @@ -1242,14 +1181,13 @@ version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1260,7 +1198,6 @@ version = "0.33.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "uvicorn-0.33.0-py3-none-any.whl", hash = "sha256:2c30de4aeea83661a520abab179b24084a0019c0c1bbe137e5409f741cbde5f8"}, {file = "uvicorn-0.33.0.tar.gz", hash = "sha256:3577119f82b7091cf4d3d4177bfda0bae4723ed92ab1439e8d779de880c9cc59"}, @@ -1272,7 +1209,7 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "wcwidth" @@ -1280,7 +1217,6 @@ version = "0.7.0" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2"}, {file = "wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0"}, @@ -1292,7 +1228,6 @@ version = "1.8.0" description = "WebSocket client for Python with low level API options" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, @@ -1309,7 +1244,6 @@ version = "13.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, @@ -1405,17 +1339,18 @@ version = "1.2.0" description = "A small Python utility to set file creation time on Windows" optional = false python-versions = ">=3.5" -groups = ["main"] -markers = "sys_platform == \"win32\"" files = [ {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, ] [package.extras] -dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + +[extras] +exa = ["exa-py"] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.9" -content-hash = "31a9791f53a36cb325ac5bac03667a12a9e86032ef272bdd606190dc857201e7" +content-hash = "73a5ad4a2af4e56fbef47b5ff0a48b15e3375c06dfbfd854ad19f3bf2d763cb7" diff --git a/pyproject.toml b/pyproject.toml index ae784bdb..62d26a0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ dynamic = ["version"] [tool.poetry] name = "smallestai" -version = "5.5.0" +version = "5.10.0" description = "" readme = "README.md" authors = [] @@ -56,6 +56,11 @@ rich = ">=14.2.0" questionary = ">=2.1.1" tomli = ">=2.3.0" tomli-w = ">=1.2.0" +# Optional prebuilt tools (smallestai.tools.*). Installed only via extras. +exa-py = { version = "*", optional = true } + +[tool.poetry.extras] +exa = ["exa-py"] [tool.poetry.scripts] smallestai = "smallestai.cli.main:main" diff --git a/reference.md b/reference.md index 9b91ef71..679619c8 100644 --- a/reference.md +++ b/reference.md @@ -22,7 +22,6 @@ client = SmallestAI( ) client.atoms.user.get_user_details() - ``` @@ -49,11 +48,29 @@ client.atoms.user.get_user_details() -## Atoms Organization -
client.atoms.organization.get_organization_details() -> GetOrganizationResponse + +
client.atoms.user.get_subscription() -> GetSubscriptionUserResponse
+#### 📝 Description + +
+
+ +
+
+ +Returns the organization's current subscription: plan id, credit balance, +renewal date, and the per-plan `limits` (agents, campaigns, numbers, daily and +concurrent calls, knowledge-base sizes) plus the `features` map that gates +capabilities like telephony, campaigns, and webhooks. Use it to check remaining +credits or whether a feature is enabled before attempting an action. +
+
+
+
+ #### 🔌 Usage
@@ -71,8 +88,7 @@ client = SmallestAI( environment=SmallestAIEnvironment.PRODUCTION, ) -client.atoms.organization.get_organization_details() - +client.atoms.user.get_subscription() ```
@@ -122,7 +138,6 @@ client = SmallestAI( ) client.atoms.agent_templates.list_agent_templates() - ``` @@ -196,7 +211,6 @@ client.atoms.agent_templates.create_agent_from_template( agent_name="agentName", template_id="templateId", ) - ``` @@ -284,7 +298,6 @@ client = SmallestAI( ) client.atoms.agents.list_agents() - ``` @@ -416,7 +429,6 @@ client = SmallestAI( client.atoms.agents.create_agent( name="name", ) - ``` @@ -749,7 +761,6 @@ client.atoms.agents.duplicate_agent( id="id", target_organization_id="60d0fe4f5311236168a109ca", ) - ``` @@ -860,7 +871,6 @@ client = SmallestAI( client.atoms.agents.get_agent( id="id", ) - ``` @@ -963,7 +973,6 @@ client = SmallestAI( client.atoms.agents.update_agent( id="id", ) - ``` @@ -1084,7 +1093,6 @@ client = SmallestAI( client.atoms.agents.get_agent_widget_config( id="id", ) - ``` @@ -1157,7 +1165,6 @@ client = SmallestAI( client.atoms.agents.update_agent_widget_config( id="id", ) - ``` @@ -1241,7 +1248,6 @@ client.atoms.agents.get_agent_avatar_presigned_url( content_type="contentType", file_size=1.1, ) - ``` @@ -1338,7 +1344,6 @@ client = SmallestAI( client.atoms.agents.get_agent_call_logs( id="id", ) - ``` @@ -1434,7 +1439,6 @@ client = SmallestAI( client.atoms.agents.archive_agent( id="id", ) - ``` @@ -1536,7 +1540,6 @@ client = SmallestAI( client.atoms.realtime.register_call( agent_id="69da0b4c20c0e03cfa4ee258", ) - ``` @@ -1647,7 +1650,6 @@ client.atoms.calls.list( date_from=datetime.datetime.fromisoformat("2025-01-01T00:00:00+00:00"), date_to=datetime.datetime.fromisoformat("2025-01-31T23:59:59+00:00"), ) - ``` @@ -1850,12 +1852,8 @@ client = SmallestAI( ) client.atoms.calls.search( - call_ids=[ - "CALL-1737000000000-abc123", - "CALL-1737000000001-def456" - ], + call_ids=["CALL-1737000000000-abc123", "CALL-1737000000001-def456"], ) - ``` @@ -1931,7 +1929,6 @@ client = SmallestAI( client.atoms.calls.get( id="CALL-1737000000000-abc123", ) - ``` @@ -2045,7 +2042,6 @@ client.atoms.calls.start_outbound_call( agent_id="60d0fe4f5311236168a109ca", phone_number="+1234567890", ) - ``` @@ -2187,7 +2183,6 @@ client = SmallestAI( client.atoms.conversations.get_a_time_limited_recording_download_url( call_id="CALL-1781127346211-e765f7", ) - ``` @@ -2265,7 +2260,6 @@ client = SmallestAI( client.atoms.conversations.list_retry_attempts( call_id="callId", ) - ``` @@ -2340,7 +2334,6 @@ client = SmallestAI( client.atoms.conversations.cancel( call_id="CALL-1778226705739-7e4c17", ) - ``` @@ -2425,7 +2418,6 @@ client = SmallestAI( client.atoms.conversations.cancel_queued( call_id="CALL-1781127346211-e765f7", ) - ``` @@ -2532,7 +2524,6 @@ client = SmallestAI( client.atoms.live_transcripts.subscribe_to_live_events( call_id="CALL-1758124225863-80752e", ) - ``` @@ -2612,7 +2603,6 @@ client = SmallestAI( ) client.atoms.campaigns.list() - ``` @@ -2727,7 +2717,6 @@ client.atoms.campaigns.create( audience_id="60d0fe4f5311236168a109ca", agent_id="60d0fe4f5311236168a109ca", ) - ``` @@ -2868,7 +2857,6 @@ client = SmallestAI( client.atoms.campaigns.get( id="id", ) - ``` @@ -2941,7 +2929,6 @@ client = SmallestAI( client.atoms.campaigns.delete( id="id", ) - ``` @@ -3018,7 +3005,6 @@ client = SmallestAI( client.atoms.campaigns.start_or_resume( id="id", ) - ``` @@ -3092,7 +3078,6 @@ client = SmallestAI( client.atoms.campaigns.pause( id="id", ) - ``` @@ -3164,7 +3149,6 @@ client = SmallestAI( ) client.atoms.knowledge_base.list() - ``` @@ -3229,7 +3213,6 @@ client = SmallestAI( client.atoms.knowledge_base.create( name="name", ) - ``` @@ -3310,7 +3293,6 @@ client = SmallestAI( client.atoms.knowledge_base.get( id="id", ) - ``` @@ -3386,7 +3368,6 @@ client.atoms.knowledge_base.update_a_knowledge_base_name_description( id="id", name="Q4 Pricing Updates", ) - ``` @@ -3479,7 +3460,6 @@ client = SmallestAI( client.atoms.knowledge_base.delete( id="id", ) - ``` @@ -3552,7 +3532,6 @@ client = SmallestAI( client.atoms.knowledge_base.get_all_knowledge_base_items( id="id", ) - ``` @@ -3626,7 +3605,6 @@ client.atoms.knowledge_base.delete_a_knowledge_base_item( knowledge_base_id="knowledgeBaseId", knowledge_base_item_id="knowledgeBaseItemId", ) - ``` @@ -3714,7 +3692,6 @@ client.atoms.knowledge_base.upload_a_pdf_file_to_a_knowledge_base( id="id", media="example_media", ) - ``` @@ -3804,7 +3781,6 @@ client.atoms.knowledge_base.get_a_presigned_s3url_for_direct_file_upload( content_type="application/pdf", knowledge_base_id="6867ca76d0f8f2e0f4201281", ) - ``` @@ -3907,7 +3883,6 @@ client.atoms.knowledge_base.complete_a_presigned_url_upload_and_start_processing key="key", file_size=1, ) - ``` @@ -4015,7 +3990,6 @@ client.atoms.knowledge_base.extract_sitemap_urls( site_url="https://example.com/sitemap.xml", knowledge_base_id="6867ca76d0f8f2e0f4201281", ) - ``` @@ -4101,12 +4075,8 @@ client = SmallestAI( client.atoms.knowledge_base.scrape_urls( id="id", - urls=[ - "https://example.com/pricing", - "https://example.com/faq" - ], + urls=["https://example.com/pricing", "https://example.com/faq"], ) - ``` @@ -4187,7 +4157,6 @@ client = SmallestAI( client.atoms.knowledge_base.list_scraped_ur_ls_in_a_knowledge_base_their_status( id="id", ) - ``` @@ -4261,7 +4230,6 @@ client.atoms.knowledge_base.delete_a_scraped_url_from_a_knowledge_base( knowledge_base_id="knowledgeBaseId", knowledge_base_scraped_urls_id="knowledgeBaseScrapedUrlsId", ) - ``` @@ -4344,7 +4312,6 @@ client = SmallestAI( ) client.atoms.phone_numbers.list() - ``` @@ -4412,7 +4379,6 @@ client = SmallestAI( ) client.atoms.phone_numbers.list_all_phone_numbers_platform_sip() - ``` @@ -4480,7 +4446,6 @@ client.atoms.phone_numbers.search_rentable( country_code="US", provider="plivo", ) - ``` @@ -4569,7 +4534,6 @@ client = SmallestAI( ) client.atoms.phone_numbers.preview_prorated_rental_cost_for_renting_a_phone_number_today() - ``` @@ -4639,7 +4603,6 @@ client.atoms.phone_numbers.rent( phone_number="13183747513", provider="plivo", ) - ``` @@ -4722,7 +4685,6 @@ client = SmallestAI( client.atoms.phone_numbers.release( product_id="6969109c84c74bed175f02a7", ) - ``` @@ -4793,7 +4755,6 @@ client = SmallestAI( ) client.atoms.phone_numbers.get_stripe_customer_portal_url() - ``` @@ -4856,7 +4817,6 @@ client = SmallestAI( ) client.atoms.phone_numbers.check_whether_the_organization_has_unpaid_invoices() - ``` @@ -4928,7 +4888,6 @@ client.atoms.phone_numbers.import_sip( sip_username="", sip_password="", ) - ``` @@ -5038,7 +4997,6 @@ client.atoms.compliance.get_compliance_status( number_type="local", user_type="individual", ) - ``` @@ -5131,7 +5089,6 @@ client.atoms.compliance.get_compliance_requirements( number_type="local", user_type="individual", ) - ``` @@ -5229,7 +5186,6 @@ client.atoms.compliance.submit( end_user="endUser", documents="documents", ) - ``` @@ -5372,7 +5328,6 @@ client.atoms.compliance.resubmit( files=["example_files"], documents="documents", ) - ``` @@ -5469,7 +5424,6 @@ client = SmallestAI( client.atoms.compliance.refresh_compliance_application_status( id="id", ) - ``` @@ -5541,7 +5495,6 @@ client = SmallestAI( ) client.atoms.webhooks.get_webhooks() - ``` @@ -5625,7 +5578,6 @@ client.atoms.webhooks.create( ) ], ) - ``` @@ -5721,7 +5673,6 @@ client = SmallestAI( client.atoms.webhooks.delete( id="id", ) - ``` @@ -5809,7 +5760,6 @@ client = SmallestAI( client.atoms.webhooks.update( id="id", ) - ``` @@ -5909,7 +5859,6 @@ client = SmallestAI( client.atoms.webhooks.get_webhook_subscriptions_for_an_agent( agent_id="agentId", ) - ``` @@ -5983,12 +5932,9 @@ client = SmallestAI( client.atoms.webhooks.replace_webhook_subscriptions_for_an_agent( agent_id="agentId", - event_types=[ - "pre-conversation" - ], + event_types=["pre-conversation"], webhook_id="60d0fe4f5311236168a109ca", ) - ``` @@ -6078,7 +6024,6 @@ client = SmallestAI( client.atoms.webhooks.delete_webhook_subscriptions_for_an_agent( agent_id="agentId", ) - ``` @@ -6150,7 +6095,6 @@ client = SmallestAI( ) client.atoms.audience.list() - ``` @@ -6222,7 +6166,6 @@ client.atoms.audience.create_audience_with_csv_upload( name="name", phone_number_column_name="phoneNumberColumnName", ) - ``` @@ -6328,7 +6271,6 @@ client = SmallestAI( client.atoms.audience.get( id="60d0fe4f5311236168a109ca", ) - ``` @@ -6407,7 +6349,6 @@ client = SmallestAI( client.atoms.audience.delete_audience( id="60d0fe4f5311236168a109ca", ) - ``` @@ -6482,7 +6423,6 @@ client.atoms.audience.get_audience_members( page=1, offset=10, ) - ``` @@ -6579,15 +6519,8 @@ client = SmallestAI( client.atoms.audience.add_audience_members( id="60d0fe4f5311236168a109ca", - members=[ - { - "phoneNumber": "+1234567890", - "name": "John Doe", - "email": "john@example.com" - } - ], + members=[{"phoneNumber": "+1234567890", "name": "John Doe", "email": "john@example.com"}], ) - ``` @@ -6667,11 +6600,8 @@ client = SmallestAI( client.atoms.audience.delete_audience_members( id="60d0fe4f5311236168a109ca", - member_ids=[ - "60d0fe4f5311236168a109cd" - ], + member_ids=["60d0fe4f5311236168a109cd"], ) - ``` @@ -6765,7 +6695,6 @@ client.atoms.audience.search_audience_members( id="60d0fe4f5311236168a109ca", query="john", ) - ``` @@ -6849,7 +6778,6 @@ client = SmallestAI( client.atoms.agent_versioning_drafts.list_active_drafts( id="id", ) - ``` @@ -6924,7 +6852,6 @@ client = SmallestAI( client.atoms.agent_versioning_drafts.create_draft( id="id", ) - ``` @@ -7027,7 +6954,6 @@ client.atoms.agent_versioning_drafts.get_draft_detail( id="id", draft_id="draftId", ) - ``` @@ -7119,7 +7045,6 @@ client.atoms.agent_versioning_drafts.discard_draft( id="id", draft_id="draftId", ) - ``` @@ -7205,7 +7130,6 @@ client.atoms.agent_versioning_drafts.rename_draft( draft_id="draftId", draft_name="draftName", ) - ``` @@ -7297,7 +7221,6 @@ client.atoms.agent_versioning_drafts.get_draft_diff( id="id", draft_id="draftId", ) - ``` @@ -7389,7 +7312,6 @@ client.atoms.agent_versioning_drafts.publish_draft( id="id", draft_id="draftId", ) - ``` @@ -7497,7 +7419,6 @@ client.atoms.agent_versioning_drafts.test_call_with_draft_config( id="id", draft_id="draftId", ) - ``` @@ -7618,7 +7539,6 @@ client.atoms.agent_versioning_drafts.update_draft_config( id="id", draft_id="draftId", ) - ``` @@ -7953,7 +7873,6 @@ client = SmallestAI( client.atoms.agent_versioning_versions.list_published_versions( id="id", ) - ``` @@ -8054,7 +7973,6 @@ client.atoms.agent_versioning_versions.diff_two_versions( version_a="versionA", version_b="versionB", ) - ``` @@ -8149,7 +8067,6 @@ client.atoms.agent_versioning_versions.get_version_detail( id="id", version_id="versionId", ) - ``` @@ -8237,7 +8154,6 @@ client.atoms.agent_versioning_versions.update_version_metadata( id="id", version_id="versionId", ) - ``` @@ -8350,7 +8266,6 @@ client.atoms.agent_versioning_versions.activate_version( id="id", version_id="versionId", ) - ``` @@ -8438,7 +8353,6 @@ client.atoms.agent_versioning_versions.test_call_with_version_config( id="id", version_id="versionId", ) - ``` @@ -8528,7 +8442,6 @@ client = SmallestAI( client.atoms.agent_versioning_branches.list( id="id", ) - ``` @@ -8603,7 +8516,6 @@ client.atoms.agent_versioning_branches.create_branch( source_branch_id="sourceBranchId", name="name", ) - ``` @@ -8693,7 +8605,6 @@ client.atoms.agent_versioning_branches.get( id="id", branch_id="branchId", ) - ``` @@ -8776,7 +8687,6 @@ client.atoms.agent_versioning_branches.rename( branch_id="branchId", name="name", ) - ``` @@ -8866,7 +8776,6 @@ client.atoms.agent_versioning_branches.archive( id="id", branch_id="branchId", ) - ``` @@ -8948,7 +8857,6 @@ client.atoms.agent_versioning_branches.make_live( id="id", branch_id="branchId", ) - ``` @@ -9030,7 +8938,6 @@ client.atoms.agent_versioning_branches.get_draft( id="id", branch_id="branchId", ) - ``` @@ -9112,7 +9019,6 @@ client.atoms.agent_versioning_branches.update_draft( id="id", branch_id="branchId", ) - ``` @@ -9402,7 +9308,6 @@ client.atoms.agent_versioning_branches.discard_draft( id="id", branch_id="branchId", ) - ``` @@ -9488,7 +9393,6 @@ client.atoms.agent_versioning_branches.publish_draft( id="id", branch_id="branchId", ) - ``` @@ -9578,7 +9482,6 @@ client.atoms.agent_versioning_branches.cancel_publish( id="id", branch_id="branchId", ) - ``` @@ -9662,7 +9565,6 @@ client.atoms.agent_versioning_branches.test_call( id="id", branch_id="branchId", ) - ``` @@ -9777,7 +9679,6 @@ client.atoms.agent_versioning_revisions.list( id="id", branch_id="branchId", ) - ``` @@ -9876,7 +9777,6 @@ client.atoms.agent_versioning_revisions.get( branch_id="branchId", revision_id="revisionId", ) - ``` @@ -9967,7 +9867,6 @@ client.atoms.agent_versioning_revisions.get_history( branch_id="branchId", revision_id="revisionId", ) - ``` @@ -10060,7 +9959,6 @@ client.atoms.agent_versioning_revisions.restore( branch_id="branchId", revision_id="revisionId", ) - ``` @@ -10151,7 +10049,6 @@ client.atoms.agent_versioning_revisions.diff( a="a", b="b", ) - ``` @@ -10270,7 +10167,6 @@ client.atoms.prompt_scoring.score_a_prompt( version_id="6a1589b75e048394eb37bc47", ), ) - ``` @@ -10342,7 +10238,6 @@ client = SmallestAI( ) client.atoms.analytics.get_call_counts_log() - ``` @@ -10461,7 +10356,6 @@ client = SmallestAI( ) client.atoms.analytics.get_call_counts_by_day() - ``` @@ -10566,7 +10460,6 @@ client = SmallestAI( client.atoms.analytics.get_conversation_details( call_id="callId", ) - ``` @@ -10637,7 +10530,6 @@ client = SmallestAI( ) client.atoms.analytics.get_usage_timeseries() - ``` @@ -10716,7 +10608,6 @@ client = SmallestAI( ) client.atoms.analytics.get_dashboard() - ``` @@ -10819,7 +10710,6 @@ client = SmallestAI( ) client.atoms.analytics.get_analytics_summary() - ``` @@ -10922,7 +10812,6 @@ client = SmallestAI( ) client.atoms.analytics.get_call_volume_timeseries() - ``` @@ -11025,7 +10914,6 @@ client = SmallestAI( ) client.atoms.analytics.get_pickup_rate_by_number() - ``` @@ -11128,7 +11016,6 @@ client = SmallestAI( ) client.atoms.analytics.get_phone_number_trends() - ``` @@ -11231,7 +11118,6 @@ client = SmallestAI( ) client.atoms.analytics.get_hourly_performance() - ``` @@ -11334,7 +11220,6 @@ client = SmallestAI( ) client.atoms.analytics.get_call_outcomes_timeseries() - ``` @@ -11437,7 +11322,6 @@ client = SmallestAI( ) client.atoms.analytics.get_duration_stats() - ``` @@ -11540,7 +11424,6 @@ client = SmallestAI( ) client.atoms.analytics.get_weekly_trends() - ``` @@ -11643,7 +11526,6 @@ client = SmallestAI( ) client.atoms.analytics.get_agent_performance() - ``` @@ -11773,7 +11655,6 @@ client = SmallestAI( client.atoms.analytics.get_analytics_concurrency( date=datetime.date.fromisoformat("2023-01-15"), ) - ``` @@ -11863,7 +11744,6 @@ client = SmallestAI( client.atoms.analytics.get_call_start_distribution( date=datetime.date.fromisoformat("2023-01-15"), ) - ``` @@ -11945,7 +11825,6 @@ client = SmallestAI( client.atoms.analytics.get_daily_call_summary( date=datetime.date.fromisoformat("2023-01-15"), ) - ``` @@ -12024,7 +11903,6 @@ client = SmallestAI( ) client.atoms.analytics.get_attempt_cohort() - ``` @@ -12130,7 +12008,6 @@ client = SmallestAI( client.atoms.call_actions.list_call_actions( agent_id="agentId", ) - ``` @@ -12242,7 +12119,6 @@ client.atoms.call_actions.create_call_action( provider="provider", config=CreateCallActionRequestConfig(), ) - ``` @@ -12355,7 +12231,6 @@ client = SmallestAI( client.atoms.call_actions.get_call_action( id="id", ) - ``` @@ -12428,7 +12303,6 @@ client = SmallestAI( client.atoms.call_actions.update_call_action( id="id", ) - ``` @@ -12541,7 +12415,6 @@ client = SmallestAI( client.atoms.call_actions.delete_call_action( id="id", ) - ``` @@ -12624,7 +12497,6 @@ client.atoms.integrations.modify_web_engage_integration( ) ], ) - ``` @@ -12697,7 +12569,6 @@ client = SmallestAI( ) client.atoms.integrations.get_web_engage_details() - ``` @@ -12761,7 +12632,6 @@ client = SmallestAI( ) client.atoms.concurrency.get_concurrency() - ``` @@ -12835,7 +12705,6 @@ client.atoms.concurrency.update_concurrency_reservations( ) ], ) - ``` @@ -12907,7 +12776,6 @@ client = SmallestAI( ) client.atoms.disposition_metric_templates.list_disposition_metric_templates() - ``` @@ -12975,7 +12843,6 @@ client = SmallestAI( ) client.atoms.dnc.list() - ``` @@ -13093,7 +12960,6 @@ client = SmallestAI( ) client.atoms.billing.get_balance() - ``` @@ -13180,7 +13046,6 @@ client.atoms.billing.get_ledger( from_=datetime.datetime.fromisoformat("2026-07-01T00:00:00+00:00"), to=datetime.datetime.fromisoformat("2026-07-28T00:00:00+00:00"), ) - ``` @@ -13294,7 +13159,6 @@ client = SmallestAI( ) client.atoms.billing.get_usage_breakdown() - ``` @@ -13364,7 +13228,6 @@ client = SmallestAI( ) client.atoms.billing.list_invoices() - ``` @@ -13434,7 +13297,6 @@ client = SmallestAI( client.atoms.billing.get_invoice_pdf( invoice_id="invoiceId", ) - ``` @@ -13506,7 +13368,6 @@ client = SmallestAI( ) client.waves.get_pronunciation_dicts() - ``` @@ -13577,7 +13438,6 @@ client.waves.create_pronunciation_dict( ) ], ) - ``` @@ -13657,7 +13517,6 @@ client.waves.update_pronunciation_dict( ) ], ) - ``` @@ -13738,7 +13597,6 @@ client = SmallestAI( client.waves.delete_pronunciation_dict( id="64f1234567890abcdef12345", ) - ``` @@ -13809,7 +13667,6 @@ client = SmallestAI( ) client.waves.synthesize_lightning() - ``` @@ -13880,7 +13737,6 @@ client = SmallestAI( ) client.waves.synthesize_lightning_large() - ``` @@ -13965,7 +13821,6 @@ client = SmallestAI( ) client.waves.synthesize_sse_lightning_large() - ``` @@ -14036,7 +13891,6 @@ client = SmallestAI( ) client.waves.synthesize_lightning_v2() - ``` @@ -14122,7 +13976,6 @@ client = SmallestAI( ) client.waves.synthesize_sse_lightning_v2() - ``` @@ -14195,7 +14048,6 @@ client = SmallestAI( client.waves.get_voices( model="lightning-v3.1", ) - ``` @@ -14360,7 +14212,6 @@ client.waves.synthesize_tts( text="Hello from Waves TTS.", voice_id="magnus", ) - ``` @@ -14482,7 +14333,6 @@ client.waves.synthesize_sse_tts( text="text", voice_id="voice_id", ) - ``` @@ -14553,7 +14403,6 @@ client = SmallestAI( ) client.waves.list_voice_clones() - ``` @@ -14619,7 +14468,6 @@ client.waves.create_voice_clone( file="example_file", display_name="displayName", ) - ``` @@ -15055,9 +14903,7 @@ client = OpenAI( response = client.chat.completions.create( model="electron", - messages=[ - {"role": "user", "content": "Write one sentence about why the sky is blue."} - ], + messages=[{"role": "user", "content": "Write one sentence about why the sky is blue."}], ) print(response.choices[0].message.content) @@ -15135,7 +14981,6 @@ client.waves.electron.complete( ) ], ) - ``` diff --git a/scripts/verify.py b/scripts/verify.py index 2d2fd5b4..b8118193 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -16,6 +16,7 @@ python scripts/verify.py # or: make verify """ + import inspect import os import pathlib @@ -33,7 +34,7 @@ def hdr(t): - print(f"\n{'='*64}\n {t}\n{'='*64}") + print(f"\n{'=' * 64}\n {t}\n{'=' * 64}") # ---------------------------------------------------------------- layer 1 @@ -109,9 +110,11 @@ def live_client(): if not key: return None from smallestai import SmallestAI + base = os.environ.get("SMALLEST_BASE_URL") if base: from smallestai.environment import SmallestAIEnvironment + base = base.rstrip("/") ws = base.replace("https://", "wss://").replace("http://", "ws://") env = SmallestAIEnvironment(atoms=f"{base}/atoms/v1", waves=base, waves_ws=ws) @@ -138,8 +141,11 @@ def read_methods(client): if not (READ.match(m_name) or m_name in EXTRA): continue try: - req = [p for p in inspect.signature(m).parameters.values() - if p.default is p.empty and p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)] + req = [ + p + for p in inspect.signature(m).parameters.values() + if p.default is p.empty and p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY) + ] except (TypeError, ValueError): req = [] if req: @@ -151,6 +157,7 @@ def read_methods(client): def check_live_sweep(client): hdr("2. LIVE READ SWEEP — every no-arg read endpoint") from smallestai.atoms.helpers import as_page + items_by_label = {} n = ok = 0 for label, method in read_methods(client): @@ -179,12 +186,14 @@ def _extras(model, path, noise={"__v"}): return found # aliases of declared fields: the unchecked-construct path can leave the raw alias # key in model_extra even when the field is populated — not a real drop. - aliases = {getattr(f, "alias", None) - for f in (type(model).model_fields.values() if hasattr(type(model), "model_fields") else [])} - for k in (getattr(model, "model_extra", None) or {}): + aliases = { + getattr(f, "alias", None) + for f in (type(model).model_fields.values() if hasattr(type(model), "model_fields") else []) + } + for k in getattr(model, "model_extra", None) or {}: if k not in noise and k not in aliases: found.append(f"{path}.{k}") - for fname in (type(model).model_fields if hasattr(type(model), "model_fields") else {}): + for fname in type(model).model_fields if hasattr(type(model), "model_fields") else {}: v = getattr(model, fname, None) if hasattr(v, "model_extra"): found += _extras(v, f"{path}.{fname}") @@ -203,8 +212,10 @@ def check_field_drop(items_by_label): if drops: total += len(drops) warnings.append(f"field-drop: {label} -> {len(drops)} untyped") - shown = ['.'.join(d.split('.')[1:]) for d in drops[:6]] - print(f" {YEL}GAP{END} {label:<46} {len(drops)} untyped: {DIM}{', '.join(shown)}{'…' if len(drops)>6 else ''}{END}") + shown = [".".join(d.split(".")[1:]) for d in drops[:6]] + print( + f" {YEL}GAP{END} {label:<46} {len(drops)} untyped: {DIM}{', '.join(shown)}{'…' if len(drops) > 6 else ''}{END}" + ) else: print(f" {GREEN}ok{END} {label:<46} all fields typed") print(f" {DIM}{total} untyped fields total (tracked as 5.1.x spec completeness){END}") diff --git a/src/smallestai/atoms/__init__.py b/src/smallestai/atoms/__init__.py index f519704a..81239943 100644 --- a/src/smallestai/atoms/__init__.py +++ b/src/smallestai/atoms/__init__.py @@ -155,6 +155,10 @@ VersioningV2MigrationRequiredResponse, VersioningV2MigrationRequiredResponseErrorType, WebEngageIntegrationSet, + WebSessionErrorResponse, + WebSessionRequest, + WebSessionResponse, + WebSessionResponseData, Webhook, WebhookAgent, WebhookEvent, @@ -206,6 +210,7 @@ UnprocessableEntityError, ) from . import ( + account, agent_templates, agent_versioning_branches, agent_versioning_drafts, @@ -226,13 +231,18 @@ integrations, knowledge_base, live_transcripts, - organization, phone_numbers, prompt_scoring, realtime, user, + web_call, webhooks, ) + from .account import ( + GetAccountDetailsResponse, + GetAccountDetailsResponseOrganizationsItem, + UpdateOrganizationNameResponse, + ) from .agent_templates import ( ListAgentTemplatesAgentTemplatesRequestRegion, ListAgentTemplatesAgentTemplatesResponse, @@ -508,6 +518,7 @@ CreateCampaignsResponseData, CreateCampaignsResponseDataStatus, DeleteCampaignsResponse, + ExportCampaignResultsByAudienceMemberRequestFormat, GetCampaignsResponse, GetCampaignsResponseData, GetCampaignsResponseDataCampaign, @@ -601,12 +612,6 @@ SubscribeToLiveEventsLiveTranscriptsResponseEventType, SubscribeToLiveEventsLiveTranscriptsResponseMetricsItem, ) - from .organization import ( - GetOrganizationResponse, - GetOrganizationResponseData, - GetOrganizationResponseDataMembersItem, - GetOrganizationResponseDataSubscription, - ) from .phone_numbers import ( GetProductAllNumbersResponse, GetProductAllNumbersResponseData, @@ -652,7 +657,13 @@ RegisterCallRealtimeResponse, RegisterCallRealtimeResponseData, ) - from .user import GetUserResponse, GetUserResponseData + from .user import ( + GetSubscriptionUserResponse, + GetSubscriptionUserResponseData, + GetSubscriptionUserResponseDataLimits, + GetUserResponse, + GetUserResponseData, + ) from .webhooks import ( CreateWebhooksRequestEventsItem, CreateWebhooksRequestEventsItemEventType, @@ -826,10 +837,13 @@ "DraftEditHistoryEntry": ".types", "DuplicateAgentAgentsResponse": ".agents", "DuplicateAgentAgentsResponseData": ".agents", + "ExportCampaignResultsByAudienceMemberRequestFormat": ".campaigns", "ExtractSitemapUrlsKnowledgeBaseResponse": ".knowledge_base", "ExtractSitemapUrlsKnowledgeBaseResponseData": ".knowledge_base", "ForbiddenError": ".errors", "ForbiddenErrorBody": ".types", + "GetAccountDetailsResponse": ".account", + "GetAccountDetailsResponseOrganizationsItem": ".account", "GetAgentAgentIdWebhookSubscriptionsResponse": ".webhooks", "GetAgentAgentsResponse": ".agents", "GetAgentAvatarPresignedUrlResponse": ".agents", @@ -955,10 +969,6 @@ "GetKnowledgebaseIdScrapedUrlsResponseDataItem": ".knowledge_base", "GetLedgerBillingRequestScope": ".billing", "GetLedgerBillingRequestType": ".billing", - "GetOrganizationResponse": ".organization", - "GetOrganizationResponseData": ".organization", - "GetOrganizationResponseDataMembersItem": ".organization", - "GetOrganizationResponseDataSubscription": ".organization", "GetPhoneNumberTrendsResponse": ".analytics", "GetPhoneNumberTrendsResponseData": ".analytics", "GetPhoneNumberTrendsResponseDataTrendsItem": ".analytics", @@ -974,6 +984,9 @@ "GetProductProrationAmountResponseData": ".phone_numbers", "GetProductUnpaidInvoicesResponse": ".phone_numbers", "GetProductUnpaidInvoicesResponseData": ".phone_numbers", + "GetSubscriptionUserResponse": ".user", + "GetSubscriptionUserResponseData": ".user", + "GetSubscriptionUserResponseDataLimits": ".user", "GetUsageTimeseriesResponse": ".analytics", "GetUsageTimeseriesResponseData": ".analytics", "GetUsageTimeseriesResponseDataDateRange": ".analytics", @@ -1209,12 +1222,17 @@ "UpdateConcurrencyReservationsResponseData": ".concurrency", "UpdateDraftAgentVersioningBranchesResponse": ".agent_versioning_branches", "UpdateDraftConfigAgentVersioningDraftsResponse": ".agent_versioning_drafts", + "UpdateOrganizationNameResponse": ".account", "UpdateVersionMetadataAgentVersioningVersionsResponse": ".agent_versioning_versions", "UpdateWebhooksResponse": ".webhooks", "VersionBlocks": ".types", "VersioningV2MigrationRequiredResponse": ".types", "VersioningV2MigrationRequiredResponseErrorType": ".types", "WebEngageIntegrationSet": ".types", + "WebSessionErrorResponse": ".types", + "WebSessionRequest": ".types", + "WebSessionResponse": ".types", + "WebSessionResponseData": ".types", "Webhook": ".types", "WebhookAgent": ".types", "WebhookEvent": ".types", @@ -1252,6 +1270,7 @@ "WorkflowGraphDataNodesItem": ".types", "WorkflowGraphDataNodesItemPosition": ".types", "WorkflowType": ".types", + "account": ".account", "agent_templates": ".agent_templates", "agent_versioning_branches": ".agent_versioning_branches", "agent_versioning_drafts": ".agent_versioning_drafts", @@ -1272,11 +1291,11 @@ "integrations": ".integrations", "knowledge_base": ".knowledge_base", "live_transcripts": ".live_transcripts", - "organization": ".organization", "phone_numbers": ".phone_numbers", "prompt_scoring": ".prompt_scoring", "realtime": ".realtime", "user": ".user", + "web_call": ".web_call", "webhooks": ".webhooks", } @@ -1462,10 +1481,13 @@ def __dir__(): "DraftEditHistoryEntry", "DuplicateAgentAgentsResponse", "DuplicateAgentAgentsResponseData", + "ExportCampaignResultsByAudienceMemberRequestFormat", "ExtractSitemapUrlsKnowledgeBaseResponse", "ExtractSitemapUrlsKnowledgeBaseResponseData", "ForbiddenError", "ForbiddenErrorBody", + "GetAccountDetailsResponse", + "GetAccountDetailsResponseOrganizationsItem", "GetAgentAgentIdWebhookSubscriptionsResponse", "GetAgentAgentsResponse", "GetAgentAvatarPresignedUrlResponse", @@ -1591,10 +1613,6 @@ def __dir__(): "GetKnowledgebaseIdScrapedUrlsResponseDataItem", "GetLedgerBillingRequestScope", "GetLedgerBillingRequestType", - "GetOrganizationResponse", - "GetOrganizationResponseData", - "GetOrganizationResponseDataMembersItem", - "GetOrganizationResponseDataSubscription", "GetPhoneNumberTrendsResponse", "GetPhoneNumberTrendsResponseData", "GetPhoneNumberTrendsResponseDataTrendsItem", @@ -1610,6 +1628,9 @@ def __dir__(): "GetProductProrationAmountResponseData", "GetProductUnpaidInvoicesResponse", "GetProductUnpaidInvoicesResponseData", + "GetSubscriptionUserResponse", + "GetSubscriptionUserResponseData", + "GetSubscriptionUserResponseDataLimits", "GetUsageTimeseriesResponse", "GetUsageTimeseriesResponseData", "GetUsageTimeseriesResponseDataDateRange", @@ -1845,12 +1866,17 @@ def __dir__(): "UpdateConcurrencyReservationsResponseData", "UpdateDraftAgentVersioningBranchesResponse", "UpdateDraftConfigAgentVersioningDraftsResponse", + "UpdateOrganizationNameResponse", "UpdateVersionMetadataAgentVersioningVersionsResponse", "UpdateWebhooksResponse", "VersionBlocks", "VersioningV2MigrationRequiredResponse", "VersioningV2MigrationRequiredResponseErrorType", "WebEngageIntegrationSet", + "WebSessionErrorResponse", + "WebSessionRequest", + "WebSessionResponse", + "WebSessionResponseData", "Webhook", "WebhookAgent", "WebhookEvent", @@ -1888,6 +1914,7 @@ def __dir__(): "WorkflowGraphDataNodesItem", "WorkflowGraphDataNodesItemPosition", "WorkflowType", + "account", "agent_templates", "agent_versioning_branches", "agent_versioning_drafts", @@ -1908,10 +1935,10 @@ def __dir__(): "integrations", "knowledge_base", "live_transcripts", - "organization", "phone_numbers", "prompt_scoring", "realtime", "user", + "web_call", "webhooks", ] diff --git a/src/smallestai/atoms/organization/__init__.py b/src/smallestai/atoms/account/__init__.py similarity index 65% rename from src/smallestai/atoms/organization/__init__.py rename to src/smallestai/atoms/account/__init__.py index 43efb7f5..0a607389 100644 --- a/src/smallestai/atoms/organization/__init__.py +++ b/src/smallestai/atoms/account/__init__.py @@ -7,16 +7,14 @@ if typing.TYPE_CHECKING: from .types import ( - GetOrganizationResponse, - GetOrganizationResponseData, - GetOrganizationResponseDataMembersItem, - GetOrganizationResponseDataSubscription, + GetAccountDetailsResponse, + GetAccountDetailsResponseOrganizationsItem, + UpdateOrganizationNameResponse, ) _dynamic_imports: typing.Dict[str, str] = { - "GetOrganizationResponse": ".types", - "GetOrganizationResponseData": ".types", - "GetOrganizationResponseDataMembersItem": ".types", - "GetOrganizationResponseDataSubscription": ".types", + "GetAccountDetailsResponse": ".types", + "GetAccountDetailsResponseOrganizationsItem": ".types", + "UpdateOrganizationNameResponse": ".types", } @@ -41,9 +39,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = [ - "GetOrganizationResponse", - "GetOrganizationResponseData", - "GetOrganizationResponseDataMembersItem", - "GetOrganizationResponseDataSubscription", -] +__all__ = ["GetAccountDetailsResponse", "GetAccountDetailsResponseOrganizationsItem", "UpdateOrganizationNameResponse"] diff --git a/src/smallestai/atoms/account/client.py b/src/smallestai/atoms/account/client.py new file mode 100644 index 00000000..9fd24444 --- /dev/null +++ b/src/smallestai/atoms/account/client.py @@ -0,0 +1,188 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from .raw_client import AsyncRawAccountClient, RawAccountClient +from .types.get_account_details_response import GetAccountDetailsResponse +from .types.update_organization_name_response import UpdateOrganizationNameResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class AccountClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawAccountClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawAccountClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawAccountClient + """ + return self._raw_client + + def get_account_details( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> GetAccountDetailsResponse: + """ + Returns the calling user's profile (email, name, avatar) along with every organization + the user belongs to. Use this to resolve the user's active org list before switching + context, or to render user info in a management UI. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetAccountDetailsResponse + Account details retrieved successfully. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.atoms.account.get_account_details() + """ + _response = self._raw_client.get_account_details(request_options=request_options) + return _response.data + + def update_organization_name( + self, *, name: str, request_options: typing.Optional[RequestOptions] = None + ) -> UpdateOrganizationNameResponse: + """ + Renames the organization scoped by the API key. Requires the `owner` role. The `name` + is what shows up in dashboards, invoices, and the org switcher. + + Parameters + ---------- + name : str + The new organization name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UpdateOrganizationNameResponse + Organization renamed. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.atoms.account.update_organization_name( + name="Acme Inc.", + ) + """ + _response = self._raw_client.update_organization_name(name=name, request_options=request_options) + return _response.data + + +class AsyncAccountClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawAccountClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawAccountClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawAccountClient + """ + return self._raw_client + + async def get_account_details( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> GetAccountDetailsResponse: + """ + Returns the calling user's profile (email, name, avatar) along with every organization + the user belongs to. Use this to resolve the user's active org list before switching + context, or to render user info in a management UI. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetAccountDetailsResponse + Account details retrieved successfully. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.atoms.account.get_account_details() + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_account_details(request_options=request_options) + return _response.data + + async def update_organization_name( + self, *, name: str, request_options: typing.Optional[RequestOptions] = None + ) -> UpdateOrganizationNameResponse: + """ + Renames the organization scoped by the API key. Requires the `owner` role. The `name` + is what shows up in dashboards, invoices, and the org switcher. + + Parameters + ---------- + name : str + The new organization name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UpdateOrganizationNameResponse + Organization renamed. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.atoms.account.update_organization_name( + name="Acme Inc.", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.update_organization_name(name=name, request_options=request_options) + return _response.data diff --git a/src/smallestai/atoms/account/raw_client.py b/src/smallestai/atoms/account/raw_client.py new file mode 100644 index 00000000..24a55c85 --- /dev/null +++ b/src/smallestai/atoms/account/raw_client.py @@ -0,0 +1,263 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.parse_error import ParsingError +from ...core.request_options import RequestOptions +from ...core.unchecked_base_model import construct_type +from ..errors.bad_request_error import BadRequestError +from ..errors.unauthorized_error import UnauthorizedError +from .types.get_account_details_response import GetAccountDetailsResponse +from .types.update_organization_name_response import UpdateOrganizationNameResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawAccountClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def get_account_details( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[GetAccountDetailsResponse]: + """ + Returns the calling user's profile (email, name, avatar) along with every organization + the user belongs to. Use this to resolve the user's active org list before switching + context, or to render user info in a management UI. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[GetAccountDetailsResponse] + Account details retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "account/get-account-details", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GetAccountDetailsResponse, + construct_type( + type_=GetAccountDetailsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def update_organization_name( + self, *, name: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[UpdateOrganizationNameResponse]: + """ + Renames the organization scoped by the API key. Requires the `owner` role. The `name` + is what shows up in dashboards, invoices, and the org switcher. + + Parameters + ---------- + name : str + The new organization name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[UpdateOrganizationNameResponse] + Organization renamed. + """ + _response = self._client_wrapper.httpx_client.request( + "account/update-org-name", + base_url=self._client_wrapper.get_environment().atoms, + method="PUT", + json={ + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UpdateOrganizationNameResponse, + construct_type( + type_=UpdateOrganizationNameResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawAccountClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def get_account_details( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[GetAccountDetailsResponse]: + """ + Returns the calling user's profile (email, name, avatar) along with every organization + the user belongs to. Use this to resolve the user's active org list before switching + context, or to render user info in a management UI. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[GetAccountDetailsResponse] + Account details retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "account/get-account-details", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GetAccountDetailsResponse, + construct_type( + type_=GetAccountDetailsResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def update_organization_name( + self, *, name: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[UpdateOrganizationNameResponse]: + """ + Renames the organization scoped by the API key. Requires the `owner` role. The `name` + is what shows up in dashboards, invoices, and the org switcher. + + Parameters + ---------- + name : str + The new organization name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[UpdateOrganizationNameResponse] + Organization renamed. + """ + _response = await self._client_wrapper.httpx_client.request( + "account/update-org-name", + base_url=self._client_wrapper.get_environment().atoms, + method="PUT", + json={ + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UpdateOrganizationNameResponse, + construct_type( + type_=UpdateOrganizationNameResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/smallestai/atoms/organization/types/__init__.py b/src/smallestai/atoms/account/types/__init__.py similarity index 53% rename from src/smallestai/atoms/organization/types/__init__.py rename to src/smallestai/atoms/account/types/__init__.py index 7d08cf6f..77ba8668 100644 --- a/src/smallestai/atoms/organization/types/__init__.py +++ b/src/smallestai/atoms/account/types/__init__.py @@ -6,15 +6,13 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .get_organization_response import GetOrganizationResponse - from .get_organization_response_data import GetOrganizationResponseData - from .get_organization_response_data_members_item import GetOrganizationResponseDataMembersItem - from .get_organization_response_data_subscription import GetOrganizationResponseDataSubscription + from .get_account_details_response import GetAccountDetailsResponse + from .get_account_details_response_organizations_item import GetAccountDetailsResponseOrganizationsItem + from .update_organization_name_response import UpdateOrganizationNameResponse _dynamic_imports: typing.Dict[str, str] = { - "GetOrganizationResponse": ".get_organization_response", - "GetOrganizationResponseData": ".get_organization_response_data", - "GetOrganizationResponseDataMembersItem": ".get_organization_response_data_members_item", - "GetOrganizationResponseDataSubscription": ".get_organization_response_data_subscription", + "GetAccountDetailsResponse": ".get_account_details_response", + "GetAccountDetailsResponseOrganizationsItem": ".get_account_details_response_organizations_item", + "UpdateOrganizationNameResponse": ".update_organization_name_response", } @@ -39,9 +37,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = [ - "GetOrganizationResponse", - "GetOrganizationResponseData", - "GetOrganizationResponseDataMembersItem", - "GetOrganizationResponseDataSubscription", -] +__all__ = ["GetAccountDetailsResponse", "GetAccountDetailsResponseOrganizationsItem", "UpdateOrganizationNameResponse"] diff --git a/src/smallestai/atoms/account/types/get_account_details_response.py b/src/smallestai/atoms/account/types/get_account_details_response.py new file mode 100644 index 00000000..1baf0d61 --- /dev/null +++ b/src/smallestai/atoms/account/types/get_account_details_response.py @@ -0,0 +1,51 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.serialization import FieldMetadata +from ....core.unchecked_base_model import UncheckedBaseModel +from .get_account_details_response_organizations_item import GetAccountDetailsResponseOrganizationsItem + + +class GetAccountDetailsResponse(UncheckedBaseModel): + user_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="userId"), + pydantic.Field(alias="userId", description="Unique user ID."), + ] = None + email: typing.Optional[str] = None + first_name: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="firstName"), pydantic.Field(alias="firstName") + ] = None + last_name: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="lastName"), pydantic.Field(alias="lastName") + ] = None + phone: typing.Optional[str] = None + picture: typing.Optional[str] = pydantic.Field(default=None) + """ + URL of the user's avatar image. + """ + + has_onboarded: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="hasOnboarded"), + pydantic.Field(alias="hasOnboarded", description="Whether the user has completed initial onboarding."), + ] = None + organizations: typing.Optional[typing.List[GetAccountDetailsResponseOrganizationsItem]] = pydantic.Field( + default=None + ) + """ + Organizations the user is a member of. `roleId` is 0 for owner, 1 for member (and higher values for finer-grained roles). + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/account/types/get_account_details_response_organizations_item.py b/src/smallestai/atoms/account/types/get_account_details_response_organizations_item.py new file mode 100644 index 00000000..60917c04 --- /dev/null +++ b/src/smallestai/atoms/account/types/get_account_details_response_organizations_item.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.serialization import FieldMetadata +from ....core.unchecked_base_model import UncheckedBaseModel + + +class GetAccountDetailsResponseOrganizationsItem(UncheckedBaseModel): + org_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="orgId"), pydantic.Field(alias="orgId") + ] = None + name: typing.Optional[str] = None + owner_email: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="ownerEmail"), pydantic.Field(alias="ownerEmail") + ] = None + role_id: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="roleId"), pydantic.Field(alias="roleId") + ] = None + on_prem_enabled: typing_extensions.Annotated[ + typing.Optional[bool], FieldMetadata(alias="onPremEnabled"), pydantic.Field(alias="onPremEnabled") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/organization/types/get_organization_response.py b/src/smallestai/atoms/account/types/update_organization_name_response.py similarity index 69% rename from src/smallestai/atoms/organization/types/get_organization_response.py rename to src/smallestai/atoms/account/types/update_organization_name_response.py index 15420d99..bf0b1d39 100644 --- a/src/smallestai/atoms/organization/types/get_organization_response.py +++ b/src/smallestai/atoms/account/types/update_organization_name_response.py @@ -5,12 +5,11 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel -from .get_organization_response_data import GetOrganizationResponseData -class GetOrganizationResponse(UncheckedBaseModel): - status: typing.Optional[bool] = None - data: typing.Optional[GetOrganizationResponseData] = None +class UpdateOrganizationNameResponse(UncheckedBaseModel): + success: typing.Optional[bool] = None + name: typing.Optional[str] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/smallestai/atoms/campaigns/client.py b/src/smallestai/atoms/campaigns/client.py index 27ac5bc5..286390e1 100644 --- a/src/smallestai/atoms/campaigns/client.py +++ b/src/smallestai/atoms/campaigns/client.py @@ -8,6 +8,10 @@ from .raw_client import AsyncRawCampaignsClient, RawCampaignsClient from .types.create_campaigns_response import CreateCampaignsResponse from .types.delete_campaigns_response import DeleteCampaignsResponse +from .types.export_campaign_results_by_audience_member_request_format import ( + ExportCampaignResultsByAudienceMemberRequestFormat, +) +from .types.export_campaign_results_by_audience_member_response import ExportCampaignResultsByAudienceMemberResponse from .types.get_campaigns_response import GetCampaignsResponse from .types.list_campaigns_request_sort_field import ListCampaignsRequestSortField from .types.list_campaigns_request_sort_order import ListCampaignsRequestSortOrder @@ -311,6 +315,90 @@ def pause(self, id: str, *, request_options: typing.Optional[RequestOptions] = N _response = self._raw_client.pause(id, request_options=request_options) return _response.data + def export_campaign_results_by_audience_member( + self, + id: str, + *, + format: typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExportCampaignResultsByAudienceMemberResponse: + """ + Returns one row per contact in the campaign audience with the outcome of every call + attempt for that contact (status, disposition, duration, cost, call ID). Use this to + reconcile a campaign run against a CRM, or to identify contacts that never connected. + The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + format : typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] + Output format. Defaults to `json`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ExportCampaignResultsByAudienceMemberResponse + The grouped export. The default `json` returns the structured object below; + `format=csv` streams `text/csv` with one row per audience-member contact. The + CSV column set matches your dashboard's campaign export for this campaign. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.atoms.campaigns.export_campaign_results_by_audience_member( + id="6a75935452c6e5eceaa16edf", + ) + """ + _response = self._raw_client.export_campaign_results_by_audience_member( + id, format=format, request_options=request_options + ) + return _response.data + + def export_campaign_logs( + self, id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> typing.Iterator[bytes]: + """ + Returns one row per call attempt in the campaign (timestamp, contact, agent, outcome, + duration, cost, recording URL, transcript URL). Use this for a flat call-level audit + trail of a campaign. The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. You can pass in configuration such as `chunk_size`, and more to customize the request and response. + + Returns + ------- + typing.Iterator[bytes] + CSV export streamed successfully. One row per call attempt. + The exact column set matches what your dashboard's campaign export produces for this + campaign; check a live export against your own campaign to confirm the column list. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.atoms.campaigns.export_campaign_logs( + id="id", + ) + """ + with self._raw_client.export_campaign_logs(id, request_options=request_options) as r: + yield from r.data + class AsyncCampaignsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -654,3 +742,104 @@ async def main() -> None: """ _response = await self._raw_client.pause(id, request_options=request_options) return _response.data + + async def export_campaign_results_by_audience_member( + self, + id: str, + *, + format: typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ExportCampaignResultsByAudienceMemberResponse: + """ + Returns one row per contact in the campaign audience with the outcome of every call + attempt for that contact (status, disposition, duration, cost, call ID). Use this to + reconcile a campaign run against a CRM, or to identify contacts that never connected. + The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + format : typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] + Output format. Defaults to `json`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ExportCampaignResultsByAudienceMemberResponse + The grouped export. The default `json` returns the structured object below; + `format=csv` streams `text/csv` with one row per audience-member contact. The + CSV column set matches your dashboard's campaign export for this campaign. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.atoms.campaigns.export_campaign_results_by_audience_member( + id="6a75935452c6e5eceaa16edf", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.export_campaign_results_by_audience_member( + id, format=format, request_options=request_options + ) + return _response.data + + async def export_campaign_logs( + self, id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> typing.AsyncIterator[bytes]: + """ + Returns one row per call attempt in the campaign (timestamp, contact, agent, outcome, + duration, cost, recording URL, transcript URL). Use this for a flat call-level audit + trail of a campaign. The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. You can pass in configuration such as `chunk_size`, and more to customize the request and response. + + Returns + ------- + typing.AsyncIterator[bytes] + CSV export streamed successfully. One row per call attempt. + The exact column set matches what your dashboard's campaign export produces for this + campaign; check a live export against your own campaign to confirm the column list. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.atoms.campaigns.export_campaign_logs( + id="id", + ) + + + asyncio.run(main()) + """ + async with self._raw_client.export_campaign_logs(id, request_options=request_options) as r: + async for _chunk in r.data: + yield _chunk diff --git a/src/smallestai/atoms/campaigns/raw_client.py b/src/smallestai/atoms/campaigns/raw_client.py index 54f2c150..fed330f0 100644 --- a/src/smallestai/atoms/campaigns/raw_client.py +++ b/src/smallestai/atoms/campaigns/raw_client.py @@ -1,5 +1,6 @@ # This file was auto-generated by Fern from our API Definition. +import contextlib import datetime as dt import typing from json.decoder import JSONDecodeError @@ -18,6 +19,10 @@ from ..errors.unauthorized_error import UnauthorizedError from .types.create_campaigns_response import CreateCampaignsResponse from .types.delete_campaigns_response import DeleteCampaignsResponse +from .types.export_campaign_results_by_audience_member_request_format import ( + ExportCampaignResultsByAudienceMemberRequestFormat, +) +from .types.export_campaign_results_by_audience_member_response import ExportCampaignResultsByAudienceMemberResponse from .types.get_campaigns_response import GetCampaignsResponse from .types.list_campaigns_request_sort_field import ListCampaignsRequestSortField from .types.list_campaigns_request_sort_order import ListCampaignsRequestSortOrder @@ -618,6 +623,142 @@ def pause( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def export_campaign_results_by_audience_member( + self, + id: str, + *, + format: typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ExportCampaignResultsByAudienceMemberResponse]: + """ + Returns one row per contact in the campaign audience with the outcome of every call + attempt for that contact (status, disposition, duration, cost, call ID). Use this to + reconcile a campaign run against a CRM, or to identify contacts that never connected. + The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + format : typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] + Output format. Defaults to `json`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ExportCampaignResultsByAudienceMemberResponse] + The grouped export. The default `json` returns the structured object below; + `format=csv` streams `text/csv` with one row per audience-member contact. The + CSV column set matches your dashboard's campaign export for this campaign. + """ + _response = self._client_wrapper.httpx_client.request( + f"campaign/{encode_path_param(id)}/export/by-audience-member", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "format": format, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ExportCampaignResultsByAudienceMemberResponse, + construct_type( + type_=ExportCampaignResultsByAudienceMemberResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + @contextlib.contextmanager + def export_campaign_logs( + self, id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> typing.Iterator[HttpResponse[typing.Iterator[bytes]]]: + """ + Returns one row per call attempt in the campaign (timestamp, contact, agent, outcome, + duration, cost, recording URL, transcript URL). Use this for a flat call-level audit + trail of a campaign. The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. You can pass in configuration such as `chunk_size`, and more to customize the request and response. + + Returns + ------- + typing.Iterator[HttpResponse[typing.Iterator[bytes]]] + CSV export streamed successfully. One row per call attempt. + The exact column set matches what your dashboard's campaign export produces for this + campaign; check a live export against your own campaign to confirm the column list. + """ + with self._client_wrapper.httpx_client.stream( + f"campaign/{encode_path_param(id)}/logs/export", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) as _response: + + def _stream() -> HttpResponse[typing.Iterator[bytes]]: + try: + if 200 <= _response.status_code < 300: + _chunk_size = request_options.get("chunk_size", None) if request_options is not None else None + return HttpResponse( + response=_response, data=(_chunk for _chunk in _response.iter_bytes(chunk_size=_chunk_size)) + ) + _response.read() + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.text + ) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.json(), + cause=e, + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + yield _stream() + class AsyncRawCampaignsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -1205,3 +1346,140 @@ async def pause( status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def export_campaign_results_by_audience_member( + self, + id: str, + *, + format: typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ExportCampaignResultsByAudienceMemberResponse]: + """ + Returns one row per contact in the campaign audience with the outcome of every call + attempt for that contact (status, disposition, duration, cost, call ID). Use this to + reconcile a campaign run against a CRM, or to identify contacts that never connected. + The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + format : typing.Optional[ExportCampaignResultsByAudienceMemberRequestFormat] + Output format. Defaults to `json`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ExportCampaignResultsByAudienceMemberResponse] + The grouped export. The default `json` returns the structured object below; + `format=csv` streams `text/csv` with one row per audience-member contact. The + CSV column set matches your dashboard's campaign export for this campaign. + """ + _response = await self._client_wrapper.httpx_client.request( + f"campaign/{encode_path_param(id)}/export/by-audience-member", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "format": format, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ExportCampaignResultsByAudienceMemberResponse, + construct_type( + type_=ExportCampaignResultsByAudienceMemberResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + @contextlib.asynccontextmanager + async def export_campaign_logs( + self, id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> typing.AsyncIterator[AsyncHttpResponse[typing.AsyncIterator[bytes]]]: + """ + Returns one row per call attempt in the campaign (timestamp, contact, agent, outcome, + duration, cost, recording URL, transcript URL). Use this for a flat call-level audit + trail of a campaign. The response streams as CSV. + + Parameters + ---------- + id : str + The campaign ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. You can pass in configuration such as `chunk_size`, and more to customize the request and response. + + Returns + ------- + typing.AsyncIterator[AsyncHttpResponse[typing.AsyncIterator[bytes]]] + CSV export streamed successfully. One row per call attempt. + The exact column set matches what your dashboard's campaign export produces for this + campaign; check a live export against your own campaign to confirm the column list. + """ + async with self._client_wrapper.httpx_client.stream( + f"campaign/{encode_path_param(id)}/logs/export", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) as _response: + + async def _stream() -> AsyncHttpResponse[typing.AsyncIterator[bytes]]: + try: + if 200 <= _response.status_code < 300: + _chunk_size = request_options.get("chunk_size", None) if request_options is not None else None + return AsyncHttpResponse( + response=_response, + data=(_chunk async for _chunk in _response.aiter_bytes(chunk_size=_chunk_size)), + ) + await _response.aread() + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.text + ) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.json(), + cause=e, + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + yield await _stream() diff --git a/src/smallestai/atoms/campaigns/types/__init__.py b/src/smallestai/atoms/campaigns/types/__init__.py index a2b1c1cc..144552e8 100644 --- a/src/smallestai/atoms/campaigns/types/__init__.py +++ b/src/smallestai/atoms/campaigns/types/__init__.py @@ -10,6 +10,13 @@ from .create_campaigns_response_data import CreateCampaignsResponseData from .create_campaigns_response_data_status import CreateCampaignsResponseDataStatus from .delete_campaigns_response import DeleteCampaignsResponse + from .export_campaign_results_by_audience_member_request_format import ( + ExportCampaignResultsByAudienceMemberRequestFormat, + ) + from .export_campaign_results_by_audience_member_response import ExportCampaignResultsByAudienceMemberResponse + from .export_campaign_results_by_audience_member_response_data_item import ( + ExportCampaignResultsByAudienceMemberResponseDataItem, + ) from .get_campaigns_response import GetCampaignsResponse from .get_campaigns_response_data import GetCampaignsResponseData from .get_campaigns_response_data_campaign import GetCampaignsResponseDataCampaign @@ -36,6 +43,9 @@ "CreateCampaignsResponseData": ".create_campaigns_response_data", "CreateCampaignsResponseDataStatus": ".create_campaigns_response_data_status", "DeleteCampaignsResponse": ".delete_campaigns_response", + "ExportCampaignResultsByAudienceMemberRequestFormat": ".export_campaign_results_by_audience_member_request_format", + "ExportCampaignResultsByAudienceMemberResponse": ".export_campaign_results_by_audience_member_response", + "ExportCampaignResultsByAudienceMemberResponseDataItem": ".export_campaign_results_by_audience_member_response_data_item", "GetCampaignsResponse": ".get_campaigns_response", "GetCampaignsResponseData": ".get_campaigns_response_data", "GetCampaignsResponseDataCampaign": ".get_campaigns_response_data_campaign", @@ -86,6 +96,9 @@ def __dir__(): "CreateCampaignsResponseData", "CreateCampaignsResponseDataStatus", "DeleteCampaignsResponse", + "ExportCampaignResultsByAudienceMemberRequestFormat", + "ExportCampaignResultsByAudienceMemberResponse", + "ExportCampaignResultsByAudienceMemberResponseDataItem", "GetCampaignsResponse", "GetCampaignsResponseData", "GetCampaignsResponseDataCampaign", diff --git a/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_request_format.py b/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_request_format.py new file mode 100644 index 00000000..0032fe3b --- /dev/null +++ b/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_request_format.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +ExportCampaignResultsByAudienceMemberRequestFormat = typing.Union[typing.Literal["json", "csv"], typing.Any] diff --git a/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_response.py b/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_response.py new file mode 100644 index 00000000..9fbc7cea --- /dev/null +++ b/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_response.py @@ -0,0 +1,37 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.serialization import FieldMetadata +from ....core.unchecked_base_model import UncheckedBaseModel +from .export_campaign_results_by_audience_member_response_data_item import ( + ExportCampaignResultsByAudienceMemberResponseDataItem, +) + + +class ExportCampaignResultsByAudienceMemberResponse(UncheckedBaseModel): + campaign: typing.Optional[str] = pydantic.Field(default=None) + """ + The campaign name. + """ + + exported_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], FieldMetadata(alias="exportedAt"), pydantic.Field(alias="exportedAt") + ] = None + total_audience_members: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalAudienceMembers"), pydantic.Field(alias="totalAudienceMembers") + ] = None + data: typing.Optional[typing.List[ExportCampaignResultsByAudienceMemberResponseDataItem]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_response_data_item.py b/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_response_data_item.py new file mode 100644 index 00000000..f74ea9d9 --- /dev/null +++ b/src/smallestai/atoms/campaigns/types/export_campaign_results_by_audience_member_response_data_item.py @@ -0,0 +1,54 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.serialization import FieldMetadata +from ....core.unchecked_base_model import UncheckedBaseModel + + +class ExportCampaignResultsByAudienceMemberResponseDataItem(UncheckedBaseModel): + audience_member_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="audienceMemberId"), pydantic.Field(alias="audienceMemberId") + ] = None + audience_member_data: typing_extensions.Annotated[ + typing.Optional[typing.Dict[str, typing.Any]], + FieldMetadata(alias="audienceMemberData"), + pydantic.Field(alias="audienceMemberData", description="The audience member's custom fields."), + ] = None + phone_number: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="phoneNumber"), pydantic.Field(alias="phoneNumber") + ] = None + original_call: typing_extensions.Annotated[ + typing.Optional[typing.Dict[str, typing.Any]], + FieldMetadata(alias="originalCall"), + pydantic.Field(alias="originalCall", description="The first call attempt for this member."), + ] = None + retry_attempts: typing_extensions.Annotated[ + typing.Optional[typing.List[typing.Dict[str, typing.Any]]], + FieldMetadata(alias="retryAttempts"), + pydantic.Field(alias="retryAttempts"), + ] = None + total_attempts: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalAttempts"), pydantic.Field(alias="totalAttempts") + ] = None + final_status: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="finalStatus"), pydantic.Field(alias="finalStatus") + ] = None + total_call_duration: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="totalCallDuration"), pydantic.Field(alias="totalCallDuration") + ] = None + total_call_cost: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="totalCallCost"), pydantic.Field(alias="totalCallCost") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/client.py b/src/smallestai/atoms/client.py index 96f20119..fd433ac3 100644 --- a/src/smallestai/atoms/client.py +++ b/src/smallestai/atoms/client.py @@ -8,6 +8,7 @@ from .raw_client import AsyncRawAtomsClient, RawAtomsClient if typing.TYPE_CHECKING: + from .account.client import AccountClient, AsyncAccountClient from .agent_templates.client import AgentTemplatesClient, AsyncAgentTemplatesClient from .agent_versioning_branches.client import AgentVersioningBranchesClient, AsyncAgentVersioningBranchesClient from .agent_versioning_drafts.client import AgentVersioningDraftsClient, AsyncAgentVersioningDraftsClient @@ -31,11 +32,11 @@ from .integrations.client import AsyncIntegrationsClient, IntegrationsClient from .knowledge_base.client import AsyncKnowledgeBaseClient, KnowledgeBaseClient from .live_transcripts.client import AsyncLiveTranscriptsClient, LiveTranscriptsClient - from .organization.client import AsyncOrganizationClient, OrganizationClient from .phone_numbers.client import AsyncPhoneNumbersClient, PhoneNumbersClient from .prompt_scoring.client import AsyncPromptScoringClient, PromptScoringClient from .realtime.client import AsyncRealtimeClient, RealtimeClient from .user.client import AsyncUserClient, UserClient + from .web_call.client import AsyncWebCallClient, WebCallClient from .webhooks.client import AsyncWebhooksClient, WebhooksClient @@ -44,7 +45,6 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): self._raw_client = RawAtomsClient(client_wrapper=client_wrapper) self._client_wrapper = client_wrapper self._user: typing.Optional[UserClient] = None - self._organization: typing.Optional[OrganizationClient] = None self._agent_templates: typing.Optional[AgentTemplatesClient] = None self._agents: typing.Optional[AgentsClient] = None self._realtime: typing.Optional[RealtimeClient] = None @@ -69,6 +69,8 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): self._disposition_metric_templates: typing.Optional[DispositionMetricTemplatesClient] = None self._dnc: typing.Optional[DncClient] = None self._billing: typing.Optional[BillingClient] = None + self._account: typing.Optional[AccountClient] = None + self._web_call: typing.Optional[WebCallClient] = None @property def with_raw_response(self) -> RawAtomsClient: @@ -89,14 +91,6 @@ def user(self): self._user = UserClient(client_wrapper=self._client_wrapper) return self._user - @property - def organization(self): - if self._organization is None: - from .organization.client import OrganizationClient # noqa: E402 - - self._organization = OrganizationClient(client_wrapper=self._client_wrapper) - return self._organization - @property def agent_templates(self): if self._agent_templates is None: @@ -289,13 +283,28 @@ def billing(self): self._billing = BillingClient(client_wrapper=self._client_wrapper) return self._billing + @property + def account(self): + if self._account is None: + from .account.client import AccountClient # noqa: E402 + + self._account = AccountClient(client_wrapper=self._client_wrapper) + return self._account + + @property + def web_call(self): + if self._web_call is None: + from .web_call.client import WebCallClient # noqa: E402 + + self._web_call = WebCallClient(client_wrapper=self._client_wrapper) + return self._web_call + class AsyncAtomsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): self._raw_client = AsyncRawAtomsClient(client_wrapper=client_wrapper) self._client_wrapper = client_wrapper self._user: typing.Optional[AsyncUserClient] = None - self._organization: typing.Optional[AsyncOrganizationClient] = None self._agent_templates: typing.Optional[AsyncAgentTemplatesClient] = None self._agents: typing.Optional[AsyncAgentsClient] = None self._realtime: typing.Optional[AsyncRealtimeClient] = None @@ -320,6 +329,8 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): self._disposition_metric_templates: typing.Optional[AsyncDispositionMetricTemplatesClient] = None self._dnc: typing.Optional[AsyncDncClient] = None self._billing: typing.Optional[AsyncBillingClient] = None + self._account: typing.Optional[AsyncAccountClient] = None + self._web_call: typing.Optional[AsyncWebCallClient] = None @property def with_raw_response(self) -> AsyncRawAtomsClient: @@ -340,14 +351,6 @@ def user(self): self._user = AsyncUserClient(client_wrapper=self._client_wrapper) return self._user - @property - def organization(self): - if self._organization is None: - from .organization.client import AsyncOrganizationClient # noqa: E402 - - self._organization = AsyncOrganizationClient(client_wrapper=self._client_wrapper) - return self._organization - @property def agent_templates(self): if self._agent_templates is None: @@ -541,3 +544,19 @@ def billing(self): self._billing = AsyncBillingClient(client_wrapper=self._client_wrapper) return self._billing + + @property + def account(self): + if self._account is None: + from .account.client import AsyncAccountClient # noqa: E402 + + self._account = AsyncAccountClient(client_wrapper=self._client_wrapper) + return self._account + + @property + def web_call(self): + if self._web_call is None: + from .web_call.client import AsyncWebCallClient # noqa: E402 + + self._web_call = AsyncWebCallClient(client_wrapper=self._client_wrapper) + return self._web_call diff --git a/src/smallestai/atoms/crew/clients/openai.py b/src/smallestai/atoms/crew/clients/openai.py index b56c220e..86b8d830 100644 --- a/src/smallestai/atoms/crew/clients/openai.py +++ b/src/smallestai/atoms/crew/clients/openai.py @@ -103,9 +103,7 @@ def electron( **kwargs, ) - def _create_client( - self, api_key: Optional[str], base_url: Optional[str] - ) -> AsyncOpenAI: + def _create_client(self, api_key: Optional[str], base_url: Optional[str]) -> AsyncOpenAI: """Create the OpenAI async client.""" # Get API key from parameter or environment api_key = api_key or os.getenv("OPENAI_API_KEY") @@ -230,8 +228,7 @@ async def chat( params = self._build_params(messages, stream, tools, overrides) logger.debug( - f"OpenAI chat request: model={params['model']}, " - f"messages={len(params['messages'])}, stream={stream}" + f"OpenAI chat request: model={params['model']}, messages={len(params['messages'])}, stream={stream}" ) if stream: @@ -239,9 +236,7 @@ async def chat( else: return await self._complete(params) - async def _stream_completion( - self, params: Dict[str, Any] - ) -> AsyncIterator[ChatChunk]: + async def _stream_completion(self, params: Dict[str, Any]) -> AsyncIterator[ChatChunk]: """Stream chat completion chunks.""" functions_list = [] arguments_list = [] @@ -251,9 +246,7 @@ async def _stream_completion( arguments = "" tool_call_id = "" - stream: AsyncStream[ - ChatCompletionChunk - ] = await self._client.chat.completions.create(**params) + stream: AsyncStream[ChatCompletionChunk] = await self._client.chat.completions.create(**params) async for chunk in stream: if not chunk.choices: @@ -280,9 +273,7 @@ async def _stream_completion( if tool_call.function and tool_call.function.arguments: arguments += tool_call.function.arguments - yield ChatChunk( - content=content, tool_calls=None, finish_reason=finish_reason - ) + yield ChatChunk(content=content, tool_calls=None, finish_reason=finish_reason) if function_name: functions_list.append(function_name) @@ -296,13 +287,9 @@ async def _stream_completion( name=function_name, arguments=arguments, ) - for tool_id, function_name, arguments in zip( - tool_id_list, functions_list, arguments_list - ) + for tool_id, function_name, arguments in zip(tool_id_list, functions_list, arguments_list) ] - yield ChatChunk( - content=None, tool_calls=tool_calls, finish_reason=finish_reason - ) + yield ChatChunk(content=None, tool_calls=tool_calls, finish_reason=finish_reason) async def _complete(self, params: Dict[str, Any]) -> ChatResponse: """Non-streaming completion.""" diff --git a/src/smallestai/atoms/crew/clients/types.py b/src/smallestai/atoms/crew/clients/types.py index 1a40a06f..b736e1cb 100644 --- a/src/smallestai/atoms/crew/clients/types.py +++ b/src/smallestai/atoms/crew/clients/types.py @@ -4,7 +4,7 @@ Common types used across different LLM providers. """ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, List, Optional diff --git a/src/smallestai/atoms/crew/events.py b/src/smallestai/atoms/crew/events.py index 4fe51ac5..e82c3382 100644 --- a/src/smallestai/atoms/crew/events.py +++ b/src/smallestai/atoms/crew/events.py @@ -112,9 +112,7 @@ class SDKAgentEvent(SDKEvent, type=EventType.AGENT_BASE.value): pass -class SDKAgentTranscriptUpdateEvent( - SDKAgentEvent, type=EventType.AGENT_TRANSCRIPT_UPDATE.value -): +class SDKAgentTranscriptUpdateEvent(SDKAgentEvent, type=EventType.AGENT_TRANSCRIPT_UPDATE.value): role: Literal["user", "assistant"] content: str @@ -199,21 +197,15 @@ class TransferOption(BaseModel): private_handoff_option: Optional[WarmTransferPrivateHandoffOption] = Field( default=None, alias="privateHandoffOption" ) - public_handoff_option: Optional[WarmTransferPublicHandoffOption] = Field( - default=None, alias="publicHandoffOption" - ) + public_handoff_option: Optional[WarmTransferPublicHandoffOption] = Field(default=None, alias="publicHandoffOption") -class SDKAgentTransferConversationEvent( - SDKAgentEvent, type=EventType.AGENT_TRANSFER_CONVERSATION.value -): +class SDKAgentTransferConversationEvent(SDKAgentEvent, type=EventType.AGENT_TRANSFER_CONVERSATION.value): transfer_call_number: str transfer_options: TransferOption # Optional: audio played to the caller while the transfer bridges. Omit (None) # to leave it to the platform default. Set a value to avoid a silent hold. - on_hold_music: Optional[ - Literal["ringtone", "relaxing_sound", "uplifting_beats", "none"] - ] = None + on_hold_music: Optional[Literal["ringtone", "relaxing_sound", "uplifting_beats", "none"]] = None class SDKSystemInitEvent(SDKSystemEvent, type=EventType.SYSTEM_INIT.value): @@ -222,25 +214,38 @@ class SDKSystemInitEvent(SDKSystemEvent, type=EventType.SYSTEM_INIT.value): output_agent_settings: Optional[OutputAgentSettings] = None -class SDKSystemUpdateOutputAgentSettingsEvent( - SDKSystemEvent, type=EventType.SYSTEM_UPDATE_OUTPUT_AGENT_SETTINGS.value -): +class SDKSystemUpdateOutputAgentSettingsEvent(SDKSystemEvent, type=EventType.SYSTEM_UPDATE_OUTPUT_AGENT_SETTINGS.value): + """Deprecated. The platform does not apply crew-sent output-agent settings. + + A crew owns only the LLM turn; platform-owned settings (voice, STT, redaction, + interruption, ...) cannot be overridden at runtime from crew code. Emitting this + event has no effect and it will be removed in a future release. + """ + settings: Dict[str, Any] + def __init__(self, **data: Any) -> None: + import warnings + + warnings.warn( + "SDKSystemUpdateOutputAgentSettingsEvent is deprecated and not applied by the " + "platform. A crew cannot override platform-owned settings (voice, STT, redaction, " + "interruption). This event will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**data) + class SDKSystemUserJoinedEvent(SDKSystemEvent, type=EventType.SYSTEM_USER_JOINED.value): pass -class SDKSystemUserStartedSpeakingEvent( - SDKSystemEvent, type=EventType.SYSTEM_USER_STARTED_SPEAKING.value -): +class SDKSystemUserStartedSpeakingEvent(SDKSystemEvent, type=EventType.SYSTEM_USER_STARTED_SPEAKING.value): pass -class SDKSystemUserStoppedSpeakingEvent( - SDKSystemEvent, type=EventType.SYSTEM_USER_STOPPED_SPEAKING.value -): +class SDKSystemUserStoppedSpeakingEvent(SDKSystemEvent, type=EventType.SYSTEM_USER_STOPPED_SPEAKING.value): pass @@ -256,9 +261,7 @@ class SDKSystemLLMRequestEvent(SDKSystemEvent, type=EventType.SYSTEM_LLM_REQUEST extra_params: Dict[str, Any] = Field(default_factory=dict) -class SDKSystemControlInterruptEvent( - SDKSystemEvent, type=EventType.SYSTEM_CONTROL_INTERRUPT.value -): +class SDKSystemControlInterruptEvent(SDKSystemEvent, type=EventType.SYSTEM_CONTROL_INTERRUPT.value): pass @@ -301,41 +304,29 @@ class SDKAgentErrorEvent(SDKAgentEvent, type=EventType.AGENT_ERROR.value): payload: Dict[str, Any] = Field(default_factory=dict) -class SDKAgentLLMResponseStartEvent( - SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_START.value -): +class SDKAgentLLMResponseStartEvent(SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_START.value): """Streaming response started.""" request_id: Optional[str] = None -class SDKAgentLLMResponseChunkEvent( - SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_CHUNK.value -): +class SDKAgentLLMResponseChunkEvent(SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_CHUNK.value): text: str -class SDKAgentLLMResponseEndEvent( - SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_END.value -): +class SDKAgentLLMResponseEndEvent(SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_END.value): pass -class SDKAgentControlInterruptEvent( - SDKAgentEvent, type=EventType.AGENT_CONTROL_INTERRUPT.value -): +class SDKAgentControlInterruptEvent(SDKAgentEvent, type=EventType.AGENT_CONTROL_INTERRUPT.value): pass -class SDKAgentControlMuteUserEvent( - SDKAgentEvent, type=EventType.AGENT_CONTROL_MUTE_USER.value -): +class SDKAgentControlMuteUserEvent(SDKAgentEvent, type=EventType.AGENT_CONTROL_MUTE_USER.value): pass -class SDKAgentControlUnmuteUserEvent( - SDKAgentEvent, type=EventType.AGENT_CONTROL_UNMUTE_USER.value -): +class SDKAgentControlUnmuteUserEvent(SDKAgentEvent, type=EventType.AGENT_CONTROL_UNMUTE_USER.value): pass diff --git a/src/smallestai/atoms/crew/nodes/base.py b/src/smallestai/atoms/crew/nodes/base.py index 4fa16602..d4556c29 100644 --- a/src/smallestai/atoms/crew/nodes/base.py +++ b/src/smallestai/atoms/crew/nodes/base.py @@ -177,24 +177,21 @@ async def __process_event_handler_loop(self): except Exception as e: severity = self._error_severity() tb_str = _traceback.format_exc() - logger.error( - f"[{self.name}] {severity.upper()} in process_event: " - f"{type(e).__name__}: {e}\n{tb_str}" - ) + logger.error(f"[{self.name}] {severity.upper()} in process_event: {type(e).__name__}: {e}\n{tb_str}") try: - await self.send_event(SDKAgentErrorEvent( - message=f"{type(e).__name__} in {self.name}.process_event: {e}", - severity=severity, - payload={ - "node_name": self.name, - "error_class": type(e).__name__, - "traceback": tb_str, - }, - )) - except Exception: - logger.exception( - f"[{self.name}] Failed to emit SDKAgentErrorEvent upstream" + await self.send_event( + SDKAgentErrorEvent( + message=f"{type(e).__name__} in {self.name}.process_event: {e}", + severity=severity, + payload={ + "node_name": self.name, + "error_class": type(e).__name__, + "traceback": tb_str, + }, + ) ) + except Exception: + logger.exception(f"[{self.name}] Failed to emit SDKAgentErrorEvent upstream") def _error_severity(self) -> str: """Return the default error severity for this node type. @@ -205,6 +202,7 @@ def _error_severity(self) -> str: """ # Local import avoids a circular dep at module-load time. from smallestai.atoms.crew.nodes.background_crew import BackgroundCrewNode + return "warning" if isinstance(self, BackgroundCrewNode) else "fatal" async def __create_process_event_task(self): diff --git a/src/smallestai/atoms/crew/nodes/output_crew.py b/src/smallestai/atoms/crew/nodes/output_crew.py index 0e6f0f87..0f0251e6 100644 --- a/src/smallestai/atoms/crew/nodes/output_crew.py +++ b/src/smallestai/atoms/crew/nodes/output_crew.py @@ -172,15 +172,17 @@ async def _handle_llm_request(self): except Exception as e: logger.exception(f"[{self.name}] Error during generation: {e}") - await self.send_event(SDKAgentErrorEvent( - message=f"{type(e).__name__} in {self.name}.generate_response: {e}", - severity="fatal", - payload={ - "node_name": self.name, - "error_class": type(e).__name__, - "traceback": _traceback.format_exc(), - }, - )) + await self.send_event( + SDKAgentErrorEvent( + message=f"{type(e).__name__} in {self.name}.generate_response: {e}", + severity="fatal", + payload={ + "node_name": self.name, + "error_class": type(e).__name__, + "traceback": _traceback.format_exc(), + }, + ) + ) finally: await self.send_event(SDKAgentLLMResponseEndEvent()) diff --git a/src/smallestai/atoms/crew/server.py b/src/smallestai/atoms/crew/server.py index ee98f01d..54e8833d 100644 --- a/src/smallestai/atoms/crew/server.py +++ b/src/smallestai/atoms/crew/server.py @@ -69,9 +69,7 @@ async def create_session( """ # Create session session_id = f"session-{uuid.uuid4()}" - session = CrewSession( - websocket=websocket, session_id=session_id, setup_handler=setup_handler - ) + session = CrewSession(websocket=websocket, session_id=session_id, setup_handler=setup_handler) await session.initialize() self._sessions[session_id] = session @@ -244,10 +242,7 @@ async def _validate_startup(self) -> None: except Exception as e: self._ready = False self._not_ready_reason = f"{type(e).__name__}: {e}" - logger.error( - f"Startup validation failed — pod will not accept sessions. " - f"{type(e).__name__}: {e}" - ) + logger.error(f"Startup validation failed — pod will not accept sessions. {type(e).__name__}: {e}") logger.exception("Full traceback:") return diff --git a/src/smallestai/atoms/crew/session.py b/src/smallestai/atoms/crew/session.py index f997a153..7629cdb5 100644 --- a/src/smallestai/atoms/crew/session.py +++ b/src/smallestai/atoms/crew/session.py @@ -165,9 +165,7 @@ async def _handle_init_event(self) -> None: bool: True if handshake successful, False otherwise """ try: - message = await asyncio.wait_for( - self.websocket.receive_json(mode="binary"), timeout=10.0 - ) + message = await asyncio.wait_for(self.websocket.receive_json(mode="binary"), timeout=10.0) logger.info(f"Received message: {message}") init_event = self.codec.decode(message) @@ -175,9 +173,7 @@ async def _handle_init_event(self) -> None: if not isinstance(init_event, SDKSystemInitEvent): logger.error(f"Expected HandshakeEvent, got {type(init_event)}") - error_event = SDKAgentErrorEvent( - message=f"Expected InitEvent, got {type(init_event)}" - ) + error_event = SDKAgentErrorEvent(message=f"Expected InitEvent, got {type(init_event)}") await self.send_to_websocket(error_event) # TODO: End the pipeline means disconnect the websocket because we have to not called the run handler @@ -230,9 +226,7 @@ async def start(self) -> None: await self.send_to_websocket(SDKAgentReadyEvent()) await self._start_nodes(self._init_event, self.task_manager) - self._receive_loop_task = self.task_manager.create_task( - self._receive_loop(), name="receive_loop" - ) + self._receive_loop_task = self.task_manager.create_task(self._receive_loop(), name="receive_loop") def _build_graph(self): """Build and validate the graph""" @@ -280,9 +274,7 @@ def dfs(node: CrewNode) -> bool: return False - async def _start_nodes( - self, init_event: SDKSystemInitEvent, task_manager: TaskManager - ): + async def _start_nodes(self, init_event: SDKSystemInitEvent, task_manager: TaskManager): """Start all nodes including sink""" for node in self.nodes: await node.start(init_event, task_manager) @@ -351,9 +343,7 @@ async def cleanup(self): if current_tasks: task_names = ", ".join(list(self.task_manager._tasks.keys())) logger.info(f"[{self.name}] Tasks: {task_names}") - logger.info( - f"[{self.name}] Waiting for {len(current_tasks)} tasks to complete" - ) + logger.info(f"[{self.name}] Waiting for {len(current_tasks)} tasks to complete") await asyncio.gather(*current_tasks, return_exceptions=True) self._cleanup_complete.set() @@ -397,9 +387,7 @@ def _register_event_handler(self, event_name: str): sync: Whether this event handler will be executed in a task. """ if event_name not in self._event_handlers: - self._event_handlers[event_name] = EventHandler( - name=event_name, handlers=[] - ) + self._event_handlers[event_name] = EventHandler(name=event_name, handlers=[]) else: logger.warning(f"Event handler {event_name} already registered") diff --git a/src/smallestai/atoms/crew/task_manager.py b/src/smallestai/atoms/crew/task_manager.py index d0f0257c..fc24ee43 100644 --- a/src/smallestai/atoms/crew/task_manager.py +++ b/src/smallestai/atoms/crew/task_manager.py @@ -227,6 +227,4 @@ def _task_done_handler(self, task: asyncio.Task): logger.debug(f"[{name}] Task done handler called") del self._tasks[name] except KeyError as e: - logger.trace( - f"{name}: unable to remove task data (already removed?): {e}" - ) + logger.trace(f"{name}: unable to remove task data (already removed?): {e}") diff --git a/src/smallestai/atoms/crew/tools/registry.py b/src/smallestai/atoms/crew/tools/registry.py index 7c9ce2a8..48fe9320 100644 --- a/src/smallestai/atoms/crew/tools/registry.py +++ b/src/smallestai/atoms/crew/tools/registry.py @@ -120,10 +120,7 @@ def get_schemas(self) -> List[Dict[str, Any]]: schemas = registry.get_schemas() response = await llm.chat(messages=[...], tools=schemas) """ - return [ - {"type": "function", "function": tool.schema.to_dict()} - for tool in self._tools.values() - ] + return [{"type": "function", "function": tool.schema.to_dict()} for tool in self._tools.values()] async def execute( self, @@ -155,9 +152,7 @@ async def execute( else: return await self._execute_sequential(tool_calls, context) - async def _execute_parallel( - self, tool_calls: List[ToolCall], context: Optional[Any] - ) -> List[ToolResult]: + async def _execute_parallel(self, tool_calls: List[ToolCall], context: Optional[Any]) -> List[ToolResult]: """Execute all tools in parallel.""" tasks = [self._execute_single(call, context) for call in tool_calls] results = await asyncio.gather(*tasks, return_exceptions=True) @@ -181,9 +176,7 @@ async def _execute_parallel( return final_results - async def _execute_sequential( - self, tool_calls: List[ToolCall], context: Optional[Any] - ) -> List[ToolResult]: + async def _execute_sequential(self, tool_calls: List[ToolCall], context: Optional[Any]) -> List[ToolResult]: """Execute tools one by one.""" results = [] for call in tool_calls: @@ -191,9 +184,7 @@ async def _execute_sequential( results.append(result) return results - async def _execute_single( - self, call: ToolCall, context: Optional[Any] - ) -> ToolResult: + async def _execute_single(self, call: ToolCall, context: Optional[Any]) -> ToolResult: """Execute a single tool call.""" try: tool_info = self._tools.get(call.name) @@ -205,9 +196,7 @@ async def _execute_single( func = tool_info.function args, kwargs = self._prepare_arguments(func, arguments, context) - logger.debug( - f"Executing tool: {call.name} with args={args}, kwargs={kwargs}" - ) + logger.debug(f"Executing tool: {call.name} with args={args}, kwargs={kwargs}") if asyncio.iscoroutinefunction(func): result = await func(*args, **kwargs) diff --git a/src/smallestai/atoms/helpers/__init__.py b/src/smallestai/atoms/helpers/__init__.py index f5c4b313..24ae62d6 100644 --- a/src/smallestai/atoms/helpers/__init__.py +++ b/src/smallestai/atoms/helpers/__init__.py @@ -1,18 +1,18 @@ """Utility classes for Atoms API operations.""" +from smallestai.atoms.helpers._envelope import Page, as_page, require_id from smallestai.atoms.helpers.agent_tools import AgentTools, AgentToolsError from smallestai.atoms.helpers.audience import Audience from smallestai.atoms.helpers.call import Call, CallAnalytics from smallestai.atoms.helpers.campaign import Campaign from smallestai.atoms.helpers.kb import KB -from smallestai.atoms.helpers._envelope import Page, as_page, require_id from smallestai.atoms.helpers.versioning import ( - Versioning, - VersioningError, - MigrationRequiredError, - DraftConflictError, BaseRevisionUnavailableError, + DraftConflictError, + MigrationRequiredError, SecurityCheckFailedError, + Versioning, + VersioningError, ) # Re-export the tool-config models so callers don't need their deep module paths diff --git a/src/smallestai/atoms/helpers/_envelope.py b/src/smallestai/atoms/helpers/_envelope.py index 8cf7c68a..57f6ca32 100644 --- a/src/smallestai/atoms/helpers/_envelope.py +++ b/src/smallestai/atoms/helpers/_envelope.py @@ -83,12 +83,18 @@ def as_page(response: Any) -> Page: if items is not None: return Page( items=list(items), - total_count=(_get(data, "total_count") or _get(data, "total") or _get(data, "count") - or _get(data, "total_campaign_count") - or _get(pagination, "total") or _get(pagination, "total_count")), + total_count=( + _get(data, "total_count") + or _get(data, "total") + or _get(data, "count") + or _get(data, "total_campaign_count") + or _get(pagination, "total") + or _get(pagination, "total_count") + ), total_pages=_get(data, "total_pages") or _get(pagination, "total_pages"), - has_more=(_get(data, "has_more") if _get(data, "has_more") is not None - else _get(pagination, "has_more")), + has_more=( + _get(data, "has_more") if _get(data, "has_more") is not None else _get(pagination, "has_more") + ), ) # Unknown shape — return the payload as a single-item page rather than guessing. diff --git a/src/smallestai/atoms/helpers/agent_tools.py b/src/smallestai/atoms/helpers/agent_tools.py index 507bc9d0..9368b8c7 100644 --- a/src/smallestai/atoms/helpers/agent_tools.py +++ b/src/smallestai/atoms/helpers/agent_tools.py @@ -185,9 +185,7 @@ def _publish_and_wait(self, agent_id: str, branch_id: str, label: str) -> str: """Publish the open draft and wait for the security scan to commit a new revision. Returns the new head revision id.""" source_head = self._branch_by_id(agent_id, branch_id).get("headRevisionId") - resp = self._post( - f"agent/{agent_id}/branches/{branch_id}/draft/publish", {"label": label} - ) + resp = self._post(f"agent/{agent_id}/branches/{branch_id}/draft/publish", {"label": label}) body = resp.json() if resp.content else {} state = ((body or {}).get("data") or {}).get("state") # 200 {state: "committed"} = synchronous; 202 {state: "scanning"} = async scan. @@ -256,7 +254,11 @@ def set_tools( logger.info( "AgentTools.set_tools agent=%s branch=%s tools=%s replace=%s make_live=%s", - agent_id, branch_id, [self._tool_name(t) for t in merged], replace, make_live, + agent_id, + branch_id, + [self._tool_name(t) for t in merged], + replace, + make_live, ) self._put( f"agent/{agent_id}/branches/{branch_id}/draft", @@ -273,9 +275,7 @@ def remove_tool(self, agent_id: str, name: str, *, make_live: bool = True) -> Li """Remove the tool with the given ``name`` (no-op if absent).""" branch = self._live_branch(agent_id) kept = [ - t - for t in self._tools_from_config(self._resolved_config(agent_id, branch)) - if self._tool_name(t) != name + t for t in self._tools_from_config(self._resolved_config(agent_id, branch)) if self._tool_name(t) != name ] logger.info("AgentTools.remove_tool agent=%s name=%s", agent_id, name) return self.set_tools(agent_id, kept, replace=True, make_live=make_live, label=f"remove {name}") # type: ignore[arg-type] @@ -317,8 +317,7 @@ def add_transfer_call( kwargs: Dict[str, Any] = dict( type="transfer_call", name=name, - description=description - or "Transfer the call to a human agent or specialist when the caller asks.", + description=description or "Transfer the call to a human agent or specialist when the caller asks.", enabled=enabled, transfer_number=number, transfer_option=ToolTransferOption(type=transfer_type), diff --git a/src/smallestai/atoms/helpers/versioning.py b/src/smallestai/atoms/helpers/versioning.py index 1bd584d8..6a6ee6dc 100644 --- a/src/smallestai/atoms/helpers/versioning.py +++ b/src/smallestai/atoms/helpers/versioning.py @@ -17,6 +17,7 @@ This is a thin wrapper over `client.atoms.agent_versioning_branches` / `.agent_versioning_revisions`, so it rides future regens without changes. """ + from __future__ import annotations import time @@ -182,9 +183,7 @@ def wait_for_commit( if sec_status == "failed": raise SecurityCheckFailedError(f"security check failed for revision {rev_id}") time.sleep(poll_interval) - raise TimeoutError( - f"revision did not reach 'published' within {timeout}s (last status={last_status})" - ) + raise TimeoutError(f"revision did not reach 'published' within {timeout}s (last status={last_status})") def publish_and_wait( self, @@ -204,9 +203,7 @@ def publish_and_wait( data = res.data if _state(data) == "committed" and getattr(data, "revision", None) is not None: return data.revision - return self.wait_for_commit( - agent_id, branch_id, timeout=timeout, poll_interval=poll_interval - ) + return self.wait_for_commit(agent_id, branch_id, timeout=timeout, poll_interval=poll_interval) def edit_and_publish( self, @@ -229,6 +226,4 @@ def edit_and_publish( if expected_revision is not None: kwargs["expected_revision"] = expected_revision self.update_draft(agent_id, branch_id, **kwargs) - return self.publish_and_wait( - agent_id, branch_id, label=label, timeout=timeout, poll_interval=poll_interval - ) + return self.publish_and_wait(agent_id, branch_id, label=label, timeout=timeout, poll_interval=poll_interval) diff --git a/src/smallestai/atoms/organization/types/get_organization_response_data.py b/src/smallestai/atoms/organization/types/get_organization_response_data.py deleted file mode 100644 index 75035c5f..00000000 --- a/src/smallestai/atoms/organization/types/get_organization_response_data.py +++ /dev/null @@ -1,33 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.serialization import FieldMetadata -from ....core.unchecked_base_model import UncheckedBaseModel -from .get_organization_response_data_members_item import GetOrganizationResponseDataMembersItem -from .get_organization_response_data_subscription import GetOrganizationResponseDataSubscription - - -class GetOrganizationResponseData(UncheckedBaseModel): - id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="_id"), pydantic.Field(alias="_id", description="The organization ID") - ] = None - name: typing.Optional[str] = pydantic.Field(default=None) - """ - The organization name - """ - - members: typing.Optional[typing.List[GetOrganizationResponseDataMembersItem]] = None - subscription: typing.Optional[GetOrganizationResponseDataSubscription] = None - - if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 - else: - - class Config: - frozen = True - smart_union = True - extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/organization/types/get_organization_response_data_members_item.py b/src/smallestai/atoms/organization/types/get_organization_response_data_members_item.py deleted file mode 100644 index 7efbeeb6..00000000 --- a/src/smallestai/atoms/organization/types/get_organization_response_data_members_item.py +++ /dev/null @@ -1,29 +0,0 @@ -# This file was auto-generated by Fern from our API Definition. - -import typing - -import pydantic -import typing_extensions -from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.serialization import FieldMetadata -from ....core.unchecked_base_model import UncheckedBaseModel - - -class GetOrganizationResponseDataMembersItem(UncheckedBaseModel): - id: typing_extensions.Annotated[ - typing.Optional[str], FieldMetadata(alias="_id"), pydantic.Field(alias="_id", description="The member ID") - ] = None - user_email: typing_extensions.Annotated[ - typing.Optional[str], - FieldMetadata(alias="userEmail"), - pydantic.Field(alias="userEmail", description="The member email"), - ] = None - - if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 - else: - - class Config: - frozen = True - smart_union = True - extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/types/__init__.py b/src/smallestai/atoms/types/__init__.py index b66c2646..37192b2f 100644 --- a/src/smallestai/atoms/types/__init__.py +++ b/src/smallestai/atoms/types/__init__.py @@ -156,6 +156,10 @@ from .versioning_v2migration_required_response import VersioningV2MigrationRequiredResponse from .versioning_v2migration_required_response_error_type import VersioningV2MigrationRequiredResponseErrorType from .web_engage_integration_set import WebEngageIntegrationSet + from .web_session_error_response import WebSessionErrorResponse + from .web_session_request import WebSessionRequest + from .web_session_response import WebSessionResponse + from .web_session_response_data import WebSessionResponseData from .webhook import Webhook from .webhook_agent import WebhookAgent from .webhook_event import WebhookEvent @@ -342,6 +346,10 @@ "VersioningV2MigrationRequiredResponse": ".versioning_v2migration_required_response", "VersioningV2MigrationRequiredResponseErrorType": ".versioning_v2migration_required_response_error_type", "WebEngageIntegrationSet": ".web_engage_integration_set", + "WebSessionErrorResponse": ".web_session_error_response", + "WebSessionRequest": ".web_session_request", + "WebSessionResponse": ".web_session_response", + "WebSessionResponseData": ".web_session_response_data", "Webhook": ".webhook", "WebhookAgent": ".webhook_agent", "WebhookEvent": ".webhook_event", @@ -552,6 +560,10 @@ def __dir__(): "VersioningV2MigrationRequiredResponse", "VersioningV2MigrationRequiredResponseErrorType", "WebEngageIntegrationSet", + "WebSessionErrorResponse", + "WebSessionRequest", + "WebSessionResponse", + "WebSessionResponseData", "Webhook", "WebhookAgent", "WebhookEvent", diff --git a/src/smallestai/atoms/types/web_session_error_response.py b/src/smallestai/atoms/types/web_session_error_response.py new file mode 100644 index 00000000..efb74bda --- /dev/null +++ b/src/smallestai/atoms/types/web_session_error_response.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel + + +class WebSessionErrorResponse(UncheckedBaseModel): + status: typing.Optional[bool] = None + errors: typing.Optional[typing.List[str]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/types/web_session_request.py b/src/smallestai/atoms/types/web_session_request.py new file mode 100644 index 00000000..b735dbe5 --- /dev/null +++ b/src/smallestai/atoms/types/web_session_request.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel + + +class WebSessionRequest(UncheckedBaseModel): + agent_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="agentId"), + pydantic.Field(alias="agentId", description="The agent to attach the browser client to."), + ] + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/types/web_session_response.py b/src/smallestai/atoms/types/web_session_response.py new file mode 100644 index 00000000..d59141dd --- /dev/null +++ b/src/smallestai/atoms/types/web_session_response.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel +from .web_session_response_data import WebSessionResponseData + + +class WebSessionResponse(UncheckedBaseModel): + status: typing.Optional[bool] = None + data: typing.Optional[WebSessionResponseData] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/types/web_session_response_data.py b/src/smallestai/atoms/types/web_session_response_data.py new file mode 100644 index 00000000..38574821 --- /dev/null +++ b/src/smallestai/atoms/types/web_session_response_data.py @@ -0,0 +1,48 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel + + +class WebSessionResponseData(UncheckedBaseModel): + token: typing.Optional[str] = pydantic.Field(default=None) + """ + Short-lived LiveKit access token. Pass on the browser client when connecting to `host`. + """ + + room_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="roomName"), + pydantic.Field(alias="roomName", description="LiveKit room UUID. Pre-created for this session."), + ] = None + host: typing.Optional[str] = pydantic.Field(default=None) + """ + LiveKit WebSocket URL to connect to. + """ + + conversation_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="conversationId"), + pydantic.Field( + alias="conversationId", description="Correlates browser events, transcripts, and post-call analytics." + ), + ] = None + call_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="callId"), + pydantic.Field(alias="callId", description="Call ID surfaced in call-logs and analytics endpoints."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/user/__init__.py b/src/smallestai/atoms/user/__init__.py index a4bbc8fc..6e029275 100644 --- a/src/smallestai/atoms/user/__init__.py +++ b/src/smallestai/atoms/user/__init__.py @@ -6,8 +6,20 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .types import GetUserResponse, GetUserResponseData -_dynamic_imports: typing.Dict[str, str] = {"GetUserResponse": ".types", "GetUserResponseData": ".types"} + from .types import ( + GetSubscriptionUserResponse, + GetSubscriptionUserResponseData, + GetSubscriptionUserResponseDataLimits, + GetUserResponse, + GetUserResponseData, + ) +_dynamic_imports: typing.Dict[str, str] = { + "GetSubscriptionUserResponse": ".types", + "GetSubscriptionUserResponseData": ".types", + "GetSubscriptionUserResponseDataLimits": ".types", + "GetUserResponse": ".types", + "GetUserResponseData": ".types", +} def __getattr__(attr_name: str) -> typing.Any: @@ -31,4 +43,10 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["GetUserResponse", "GetUserResponseData"] +__all__ = [ + "GetSubscriptionUserResponse", + "GetSubscriptionUserResponseData", + "GetSubscriptionUserResponseDataLimits", + "GetUserResponse", + "GetUserResponseData", +] diff --git a/src/smallestai/atoms/user/client.py b/src/smallestai/atoms/user/client.py index 69d76627..fd67c67d 100644 --- a/src/smallestai/atoms/user/client.py +++ b/src/smallestai/atoms/user/client.py @@ -5,6 +5,7 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...core.request_options import RequestOptions from .raw_client import AsyncRawUserClient, RawUserClient +from .types.get_subscription_user_response import GetSubscriptionUserResponse from .types.get_user_response import GetUserResponse @@ -47,6 +48,38 @@ def get_user_details(self, *, request_options: typing.Optional[RequestOptions] = _response = self._raw_client.get_user_details(request_options=request_options) return _response.data + def get_subscription( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> GetSubscriptionUserResponse: + """ + Returns the organization's current subscription: plan id, credit balance, + renewal date, and the per-plan `limits` (agents, campaigns, numbers, daily and + concurrent calls, knowledge-base sizes) plus the `features` map that gates + capabilities like telephony, campaigns, and webhooks. Use it to check remaining + credits or whether a feature is enabled before attempting an action. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetSubscriptionUserResponse + Successful response + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.atoms.user.get_subscription() + """ + _response = self._raw_client.get_subscription(request_options=request_options) + return _response.data + class AsyncUserClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -94,3 +127,43 @@ async def main() -> None: """ _response = await self._raw_client.get_user_details(request_options=request_options) return _response.data + + async def get_subscription( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> GetSubscriptionUserResponse: + """ + Returns the organization's current subscription: plan id, credit balance, + renewal date, and the per-plan `limits` (agents, campaigns, numbers, daily and + concurrent calls, knowledge-base sizes) plus the `features` map that gates + capabilities like telephony, campaigns, and webhooks. Use it to check remaining + credits or whether a feature is enabled before attempting an action. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetSubscriptionUserResponse + Successful response + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.atoms.user.get_subscription() + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_subscription(request_options=request_options) + return _response.data diff --git a/src/smallestai/atoms/user/raw_client.py b/src/smallestai/atoms/user/raw_client.py index 06ada627..6105295b 100644 --- a/src/smallestai/atoms/user/raw_client.py +++ b/src/smallestai/atoms/user/raw_client.py @@ -11,6 +11,7 @@ from ...core.unchecked_base_model import construct_type from ..errors.internal_server_error import InternalServerError from ..errors.unauthorized_error import UnauthorizedError +from .types.get_subscription_user_response import GetSubscriptionUserResponse from .types.get_user_response import GetUserResponse from pydantic import ValidationError @@ -80,6 +81,73 @@ def get_user_details( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def get_subscription( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[GetSubscriptionUserResponse]: + """ + Returns the organization's current subscription: plan id, credit balance, + renewal date, and the per-plan `limits` (agents, campaigns, numbers, daily and + concurrent calls, knowledge-base sizes) plus the `features` map that gates + capabilities like telephony, campaigns, and webhooks. Use it to check remaining + credits or whether a feature is enabled before attempting an action. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[GetSubscriptionUserResponse] + Successful response + """ + _response = self._client_wrapper.httpx_client.request( + "user/subscription", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GetSubscriptionUserResponse, + construct_type( + type_=GetSubscriptionUserResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + class AsyncRawUserClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -145,3 +213,70 @@ async def get_user_details( status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_subscription( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[GetSubscriptionUserResponse]: + """ + Returns the organization's current subscription: plan id, credit balance, + renewal date, and the per-plan `limits` (agents, campaigns, numbers, daily and + concurrent calls, knowledge-base sizes) plus the `features` map that gates + capabilities like telephony, campaigns, and webhooks. Use it to check remaining + credits or whether a feature is enabled before attempting an action. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[GetSubscriptionUserResponse] + Successful response + """ + _response = await self._client_wrapper.httpx_client.request( + "user/subscription", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GetSubscriptionUserResponse, + construct_type( + type_=GetSubscriptionUserResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/smallestai/atoms/user/types/__init__.py b/src/smallestai/atoms/user/types/__init__.py index f6f4b180..fe4f3732 100644 --- a/src/smallestai/atoms/user/types/__init__.py +++ b/src/smallestai/atoms/user/types/__init__.py @@ -6,9 +6,15 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .get_subscription_user_response import GetSubscriptionUserResponse + from .get_subscription_user_response_data import GetSubscriptionUserResponseData + from .get_subscription_user_response_data_limits import GetSubscriptionUserResponseDataLimits from .get_user_response import GetUserResponse from .get_user_response_data import GetUserResponseData _dynamic_imports: typing.Dict[str, str] = { + "GetSubscriptionUserResponse": ".get_subscription_user_response", + "GetSubscriptionUserResponseData": ".get_subscription_user_response_data", + "GetSubscriptionUserResponseDataLimits": ".get_subscription_user_response_data_limits", "GetUserResponse": ".get_user_response", "GetUserResponseData": ".get_user_response_data", } @@ -35,4 +41,10 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["GetUserResponse", "GetUserResponseData"] +__all__ = [ + "GetSubscriptionUserResponse", + "GetSubscriptionUserResponseData", + "GetSubscriptionUserResponseDataLimits", + "GetUserResponse", + "GetUserResponseData", +] diff --git a/src/smallestai/atoms/organization/types/get_organization_response_data_subscription.py b/src/smallestai/atoms/user/types/get_subscription_user_response.py similarity index 59% rename from src/smallestai/atoms/organization/types/get_organization_response_data_subscription.py rename to src/smallestai/atoms/user/types/get_subscription_user_response.py index e7bb2970..52fe1ee0 100644 --- a/src/smallestai/atoms/organization/types/get_organization_response_data_subscription.py +++ b/src/smallestai/atoms/user/types/get_subscription_user_response.py @@ -3,18 +3,17 @@ import typing import pydantic -import typing_extensions from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.serialization import FieldMetadata from ....core.unchecked_base_model import UncheckedBaseModel +from .get_subscription_user_response_data import GetSubscriptionUserResponseData -class GetOrganizationResponseDataSubscription(UncheckedBaseModel): - plan_id: typing_extensions.Annotated[ - typing.Optional[str], - FieldMetadata(alias="planId"), - pydantic.Field(alias="planId", description="The subscription plan ID"), - ] = None +class GetSubscriptionUserResponse(UncheckedBaseModel): + status: typing.Optional[bool] = None + data: typing.Optional[GetSubscriptionUserResponseData] = pydantic.Field(default=None) + """ + The subscription record. Extra fields may be present. + """ if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/smallestai/atoms/user/types/get_subscription_user_response_data.py b/src/smallestai/atoms/user/types/get_subscription_user_response_data.py new file mode 100644 index 00000000..e6faeda9 --- /dev/null +++ b/src/smallestai/atoms/user/types/get_subscription_user_response_data.py @@ -0,0 +1,62 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.serialization import FieldMetadata +from ....core.unchecked_base_model import UncheckedBaseModel +from .get_subscription_user_response_data_limits import GetSubscriptionUserResponseDataLimits + + +class GetSubscriptionUserResponseData(UncheckedBaseModel): + """ + The subscription record. Extra fields may be present. + """ + + id: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="_id"), pydantic.Field(alias="_id")] = ( + None + ) + organization: typing.Optional[str] = None + plan_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="planId"), + pydantic.Field(alias="planId", description="The active plan identifier."), + ] = None + total_credits: typing_extensions.Annotated[ + typing.Optional[float], FieldMetadata(alias="totalCredits"), pydantic.Field(alias="totalCredits") + ] = None + current_credits: typing_extensions.Annotated[ + typing.Optional[float], + FieldMetadata(alias="currentCredits"), + pydantic.Field(alias="currentCredits", description="Credits remaining in the current cycle."), + ] = None + renewal_date: typing_extensions.Annotated[ + typing.Optional[dt.datetime], FieldMetadata(alias="renewalDate"), pydantic.Field(alias="renewalDate") + ] = None + subscription_active: typing_extensions.Annotated[ + typing.Optional[bool], FieldMetadata(alias="subscriptionActive"), pydantic.Field(alias="subscriptionActive") + ] = None + on_prem_enabled: typing_extensions.Annotated[ + typing.Optional[bool], FieldMetadata(alias="onPremEnabled"), pydantic.Field(alias="onPremEnabled") + ] = None + limits: typing.Optional[GetSubscriptionUserResponseDataLimits] = pydantic.Field(default=None) + """ + Per-plan quotas. + """ + + features: typing.Optional[typing.Dict[str, bool]] = pydantic.Field(default=None) + """ + Feature flags gating capabilities for this plan. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/user/types/get_subscription_user_response_data_limits.py b/src/smallestai/atoms/user/types/get_subscription_user_response_data_limits.py new file mode 100644 index 00000000..337e2953 --- /dev/null +++ b/src/smallestai/atoms/user/types/get_subscription_user_response_data_limits.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.serialization import FieldMetadata +from ....core.unchecked_base_model import UncheckedBaseModel + + +class GetSubscriptionUserResponseDataLimits(UncheckedBaseModel): + """ + Per-plan quotas. + """ + + agents: typing.Optional[int] = None + campaigns: typing.Optional[int] = None + numbers: typing.Optional[int] = None + daily_calls: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="dailyCalls"), pydantic.Field(alias="dailyCalls") + ] = None + concurrent_calls: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="concurrentCalls"), pydantic.Field(alias="concurrentCalls") + ] = None + knowledge_base_item_limit: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="knowledgeBaseItemLimit"), + pydantic.Field(alias="knowledgeBaseItemLimit"), + ] = None + knowledge_base_limits: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="knowledgeBaseLimits"), pydantic.Field(alias="knowledgeBaseLimits") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/atoms/web_call/__init__.py b/src/smallestai/atoms/web_call/__init__.py new file mode 100644 index 00000000..5cde0202 --- /dev/null +++ b/src/smallestai/atoms/web_call/__init__.py @@ -0,0 +1,4 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + diff --git a/src/smallestai/atoms/web_call/client.py b/src/smallestai/atoms/web_call/client.py new file mode 100644 index 00000000..17c0f9eb --- /dev/null +++ b/src/smallestai/atoms/web_call/client.py @@ -0,0 +1,209 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from ..types.web_session_response import WebSessionResponse +from .raw_client import AsyncRawWebCallClient, RawWebCallClient + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class WebCallClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawWebCallClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawWebCallClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawWebCallClient + """ + return self._raw_client + + def start_web_chat_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> WebSessionResponse: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a text-first chat with the agent. The response includes the room name, + the LiveKit host to connect to, a `conversationId` for correlation, and a `callId` + that shows up in call logs. Pair with the [Web SDK](/voice-agents/developer-guide/client-libraries) + to render the session in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebSessionResponse + Session minted. Use `token` + `host` on the browser client to join `roomName`. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.atoms.web_call.start_web_chat_conversation( + agent_id="6a75935452c6e5eceaa16edf", + ) + """ + _response = self._raw_client.start_web_chat_conversation(agent_id=agent_id, request_options=request_options) + return _response.data + + def start_web_call_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> WebSessionResponse: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a voice-first call with the agent. Same response shape as `/conversation/chat`; + the difference is the audio track defaults on the client side. Pair with the + [Web SDK](/voice-agents/developer-guide/client-libraries) to open the room in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebSessionResponse + Session minted. Use `token` + `host` on the browser client to join `roomName`. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.atoms.web_call.start_web_call_conversation( + agent_id="6a75935452c6e5eceaa16edf", + ) + """ + _response = self._raw_client.start_web_call_conversation(agent_id=agent_id, request_options=request_options) + return _response.data + + +class AsyncWebCallClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawWebCallClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawWebCallClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawWebCallClient + """ + return self._raw_client + + async def start_web_chat_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> WebSessionResponse: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a text-first chat with the agent. The response includes the room name, + the LiveKit host to connect to, a `conversationId` for correlation, and a `callId` + that shows up in call logs. Pair with the [Web SDK](/voice-agents/developer-guide/client-libraries) + to render the session in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebSessionResponse + Session minted. Use `token` + `host` on the browser client to join `roomName`. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.atoms.web_call.start_web_chat_conversation( + agent_id="6a75935452c6e5eceaa16edf", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.start_web_chat_conversation( + agent_id=agent_id, request_options=request_options + ) + return _response.data + + async def start_web_call_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> WebSessionResponse: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a voice-first call with the agent. Same response shape as `/conversation/chat`; + the difference is the audio track defaults on the client side. Pair with the + [Web SDK](/voice-agents/developer-guide/client-libraries) to open the room in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebSessionResponse + Session minted. Use `token` + `host` on the browser client to join `roomName`. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.atoms.web_call.start_web_call_conversation( + agent_id="6a75935452c6e5eceaa16edf", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.start_web_call_conversation( + agent_id=agent_id, request_options=request_options + ) + return _response.data diff --git a/src/smallestai/atoms/web_call/raw_client.py b/src/smallestai/atoms/web_call/raw_client.py new file mode 100644 index 00000000..2dc6f95b --- /dev/null +++ b/src/smallestai/atoms/web_call/raw_client.py @@ -0,0 +1,277 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.parse_error import ParsingError +from ...core.request_options import RequestOptions +from ...core.unchecked_base_model import construct_type +from ..errors.bad_request_error import BadRequestError +from ..types.web_session_response import WebSessionResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawWebCallClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def start_web_chat_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[WebSessionResponse]: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a text-first chat with the agent. The response includes the room name, + the LiveKit host to connect to, a `conversationId` for correlation, and a `callId` + that shows up in call logs. Pair with the [Web SDK](/voice-agents/developer-guide/client-libraries) + to render the session in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[WebSessionResponse] + Session minted. Use `token` + `host` on the browser client to join `roomName`. + """ + _response = self._client_wrapper.httpx_client.request( + "conversation/chat", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "agentId": agent_id, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebSessionResponse, + construct_type( + type_=WebSessionResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def start_web_call_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[WebSessionResponse]: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a voice-first call with the agent. Same response shape as `/conversation/chat`; + the difference is the audio track defaults on the client side. Pair with the + [Web SDK](/voice-agents/developer-guide/client-libraries) to open the room in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[WebSessionResponse] + Session minted. Use `token` + `host` on the browser client to join `roomName`. + """ + _response = self._client_wrapper.httpx_client.request( + "conversation/webcall", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "agentId": agent_id, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebSessionResponse, + construct_type( + type_=WebSessionResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawWebCallClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def start_web_chat_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[WebSessionResponse]: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a text-first chat with the agent. The response includes the room name, + the LiveKit host to connect to, a `conversationId` for correlation, and a `callId` + that shows up in call logs. Pair with the [Web SDK](/voice-agents/developer-guide/client-libraries) + to render the session in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[WebSessionResponse] + Session minted. Use `token` + `host` on the browser client to join `roomName`. + """ + _response = await self._client_wrapper.httpx_client.request( + "conversation/chat", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "agentId": agent_id, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebSessionResponse, + construct_type( + type_=WebSessionResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def start_web_call_conversation( + self, *, agent_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[WebSessionResponse]: + """ + Mints a short-lived LiveKit access token and creates a room the browser client can + join to have a voice-first call with the agent. Same response shape as `/conversation/chat`; + the difference is the audio track defaults on the client side. Pair with the + [Web SDK](/voice-agents/developer-guide/client-libraries) to open the room in-browser. + + Parameters + ---------- + agent_id : str + The agent to attach the browser client to. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[WebSessionResponse] + Session minted. Use `token` + `host` on the browser client to join `roomName`. + """ + _response = await self._client_wrapper.httpx_client.request( + "conversation/webcall", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "agentId": agent_id, + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebSessionResponse, + construct_type( + type_=WebSessionResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/smallestai/cli/agent_crew.py b/src/smallestai/cli/agent_crew.py index d5ad60db..f2967545 100644 --- a/src/smallestai/cli/agent_crew.py +++ b/src/smallestai/cli/agent_crew.py @@ -16,6 +16,8 @@ from smallestai.cli.lib.atoms import AgentBuildStatus, AtomsAPIClient from smallestai.cli.lib.auth import AuthClient from smallestai.cli.lib.chat import ChatClient, chat_loop +from smallestai.cli.lib.ownership import SUMMARY as OWNERSHIP_SUMMARY +from smallestai.cli.lib.ownership import render_ownership from smallestai.cli.lib.project_config import ProjectConfig from smallestai.cli.utils import create_zip_from_directory, find_required_env_vars @@ -31,9 +33,7 @@ console = Console() -def initialise_agent_crew_app( - project_config: ProjectConfig, auth_client: AuthClient, atoms_client: AtomsAPIClient -): +def initialise_agent_crew_app(project_config: ProjectConfig, auth_client: AuthClient, atoms_client: AtomsAPIClient): app = typer.Typer(name="agent-crew") @app.command() @@ -52,32 +52,25 @@ async def async_init(agent_id_arg: Optional[str] = None): agent_id = project_config.get_agent_id() if agent_id: - console.print( - f"[green]Agent already initialized with ID: [bold]{agent_id}[/bold][/green]" - ) + console.print(f"[green]Agent already initialized with ID: [bold]{agent_id}[/bold][/green]") return # Non-interactive path: link the provided id directly, no picker. if agent_id_arg: project_config.set_agent_id(agent_id_arg) - console.print( - f"[green]Agent initialized successfully with ID: [bold]{agent_id_arg}[/bold][/green]" - ) + console.print(f"[green]Agent initialized successfully with ID: [bold]{agent_id_arg}[/bold][/green]") + _print_ownership_hint() return # Without an id, the picker needs a real TTY — fail clearly instead of crashing. if not sys.stdin.isatty(): - console.print( - "[red]init requires an interactive terminal. Pass --agent-id to skip the picker.[/red]" - ) + console.print("[red]init requires an interactive terminal. Pass --agent-id to skip the picker.[/red]") raise typer.Exit(1) # Check if user is logged in credentials = auth_client.get_credentials() if not credentials or not credentials.get("access_token"): - console.print( - "[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]" - ) + console.print("[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]") raise typer.Exit(1) access_token = credentials["access_token"] @@ -115,6 +108,17 @@ async def async_init(agent_id_arg: Optional[str] = None): project_config.set_agent_id(selected_agent) console.print("[green]Agent initialized successfully![/green]") + _print_ownership_hint() + + def _print_ownership_hint(): + console.print( + Panel( + OWNERSHIP_SUMMARY + "\n\n[dim]Run [bold]smallestai agent-crew doctor[/bold] to check this " + "agent's config for common gotchas.[/dim]", + title="What your crew controls vs the platform", + border_style="cyan", + ) + ) @app.command() def deploy( @@ -137,17 +141,13 @@ async def async_deploy(directory: str, entry_point: str): agent_id = project_config.get_agent_id() if not agent_id: - console.print( - "[red]Agent not initialized. Run 'smallestai agent init' first.[/red]" - ) + console.print("[red]Agent not initialized. Run 'smallestai agent init' first.[/red]") return # Check if user is logged in credentials = auth_client.get_credentials() if not credentials or not credentials.get("access_token"): - console.print( - "[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]" - ) + console.print("[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]") raise typer.Exit(1) access_token = credentials["access_token"] @@ -164,14 +164,10 @@ async def async_deploy(directory: str, entry_point: str): # Check if entry point file exists entry_point_path = dir_path / entry_point if not entry_point_path.exists(): - console.print( - f"[red]Error: Entry point file '{entry_point}' not found in '{directory}'.[/red]" - ) + console.print(f"[red]Error: Entry point file '{entry_point}' not found in '{directory}'.[/red]") return - console.print( - f"[bold cyan]Deploying agent from: {dir_path.absolute()}[/bold cyan]" - ) + console.print(f"[bold cyan]Deploying agent from: {dir_path.absolute()}[/bold cyan]") console.print(f"[dim]Entry point: {entry_point}[/dim]") console.print(f"[dim]Agent ID: {agent_id}[/dim]\n") @@ -204,9 +200,7 @@ async def async_deploy(directory: str, entry_point: str): # SMALLEST_API_KEY is set by the platform; ignore from the warning list. required_env_vars.discard("SMALLEST_API_KEY") if required_env_vars: - console.print( - "[yellow]⚠ Your code references these environment variables:[/yellow]" - ) + console.print("[yellow]⚠ Your code references these environment variables:[/yellow]") for var in sorted(required_env_vars): console.print(f" [yellow]• {var}[/yellow]") console.print( @@ -271,13 +265,11 @@ async def async_deploy(directory: str, entry_point: str): ) elif status in terminal: console.print( - f"[red]Build ended with status: {status}. " - "Check `smallestai agent-crew builds` for details.[/red]" + f"[red]Build ended with status: {status}. Check `smallestai agent-crew builds` for details.[/red]" ) else: console.print( - "[yellow]Still building. Check `smallestai agent-crew builds` " - "for the final status.[/yellow]" + "[yellow]Still building. Check `smallestai agent-crew builds` for the final status.[/yellow]" ) except Exception as e: @@ -322,12 +314,8 @@ async def run(): @app.command("builds") def list_builds( - build_id: str = typer.Argument( - None, help="Optional build ID to manage directly" - ), - limit: int = typer.Option( - 50, "--limit", "-l", help="Number of builds to fetch" - ), + build_id: str = typer.Argument(None, help="Optional build ID to manage directly"), + limit: int = typer.Option(50, "--limit", "-l", help="Number of builds to fetch"), offset: int = typer.Option(0, "--offset", "-o", help="Offset for pagination"), ): """ @@ -342,16 +330,12 @@ async def async_list_builds(build_id: str | None, limit: int, offset: int): agent_id = project_config.get_agent_id() if not agent_id: - console.print( - "[red]Agent not initialized. Run 'smallestai agent init' first.[/red]" - ) + console.print("[red]Agent not initialized. Run 'smallestai agent init' first.[/red]") return credentials = auth_client.get_credentials() if not credentials or not credentials.get("access_token"): - console.print( - "[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]" - ) + console.print("[red]Error: You must be logged in first. Run 'smallestai auth login'[/red]") raise typer.Exit(1) access_token = credentials["access_token"] @@ -388,9 +372,7 @@ async def async_list_builds(build_id: str | None, limit: int, offset: int): for build in result.builds: status_color = AGENT_BUILD_STATUS_COLORS[build.status] - status_text = ( - f"[{status_color}]{build.status.value.upper()}[/{status_color}]" - ) + status_text = f"[{status_color}]{build.status.value.upper()}[/{status_color}]" live_indicator = "[green]✓ LIVE[/green]" if build.is_live else "-" @@ -402,9 +384,7 @@ async def async_list_builds(build_id: str | None, limit: int, offset: int): ) console.print(table) - console.print( - f"[dim]Showing {len(result.builds)} of {result.pagination.total} builds[/dim]\n" - ) + console.print(f"[dim]Showing {len(result.builds)} of {result.pagination.total} builds[/dim]\n") choices = [ questionary.Choice( @@ -485,9 +465,7 @@ async def _manage_build(agent_id: str, build, access_token: str): api_key=access_token, is_live=True, ) - console.print( - f"[bold green]✓ Build {build.id[:12]}... is now LIVE![/bold green]" - ) + console.print(f"[bold green]✓ Build {build.id[:12]}... is now LIVE![/bold green]") elif selected_action == "take_down": console.print("[yellow]Taking down build...[/yellow]") await atoms_client.update_agent_build( @@ -496,9 +474,7 @@ async def _manage_build(agent_id: str, build, access_token: str): api_key=access_token, is_live=False, ) - console.print( - f"[bold green]✓ Build {build.id[:12]}... has been taken down.[/bold green]" - ) + console.print(f"[bold green]✓ Build {build.id[:12]}... has been taken down.[/bold green]") # @app.command("logs") # def stream_build( @@ -565,4 +541,84 @@ async def _manage_build(agent_id: str, build, access_token: str): # except Exception as e: # console.print(f"[red]Error streaming build logs: {e}[/red]") + @app.command() + def doctor( + agent_id: Optional[str] = typer.Option( + None, + "--agent-id", + help="Agent id to inspect. Defaults to the agent linked in this project.", + ), + ): + """Check a crew agent's config for common gotchas (live build, redaction, dashboard tools).""" + asyncio.run(async_doctor(agent_id)) + + async def async_doctor(agent_id_arg: Optional[str]): + agent_id = agent_id_arg or project_config.get_agent_id() + if not agent_id: + console.print("[red]No agent id. Pass --agent-id , or run `init` first.[/red]") + raise typer.Exit(1) + + credentials = auth_client.get_credentials() + if not credentials or not credentials.get("access_token"): + console.print("[red]You must be logged in. Run 'smallestai auth login'.[/red]") + raise typer.Exit(1) + token = credentials["access_token"] + + console.print(f"[dim]Inspecting agent {agent_id}...[/dim]") + try: + agent = await atoms_client.get_agent_raw(token, agent_id) + except Exception as e: + console.print(f"[red]Could not fetch agent: {e}[/red]") + raise typer.Exit(1) + try: + builds = await atoms_client.list_agent_builds(agent_id, token) + build_items = builds.builds + except Exception: + build_items = [] + + oks: list[str] = [] + warns: list[str] = [] + + live = [b for b in build_items if b.is_live] + if live: + oks.append(f"A crew build is live ({live[0].id[:12]}...).") + else: + warns.append( + "No live crew build. Run `agent-crew deploy`, then make the build live, " + "or your crew code will not serve." + ) + + wft = agent.get("workflowType") + if wft and wft != "single_prompt": + warns.append(f"workflowType is '{wft}'. A crew attaches to single_prompt agents.") + + if (agent.get("redactionConfig") or {}).get("isEnabled"): + warns.append( + "PII redaction is ON. It rewrites emails/names/numbers in the transcript " + "(and can trim leading words like 'my email address is'). Set " + "redactionConfig.isEnabled off if you don't want it." + ) + else: + oks.append("PII redaction is off.") + + sp = (agent.get("workflow") or {}).get("singlePromptConfig") or agent.get("singlePromptConfig") or {} + tools = sp.get("tools") or [] + tool_repr = " ".join(str(t).lower() for t in tools) + if "transfer" in tool_repr: + warns.append( + "The dashboard Tools panel has transfer_call enabled. For a crew agent that " + "panel is ignored (your code-side @function_tool is used). Leave it off to " + "avoid confusion." + ) + + console.print() + for m in oks: + console.print(f"[green]OK[/green] {m}") + for m in warns: + console.print(f"[yellow]WARN[/yellow] {m}") + if not warns: + console.print("[bold green]No issues found.[/bold green]") + console.print() + render_ownership(console) + return app diff --git a/src/smallestai/cli/agents.py b/src/smallestai/cli/agents.py index e022a9ba..6631c837 100644 --- a/src/smallestai/cli/agents.py +++ b/src/smallestai/cli/agents.py @@ -5,6 +5,7 @@ key stored by `smallestai auth login` (~/.smallestai/credentials.json). SMALLEST_BASE_URL overrides the endpoint (dev rig). """ + import typer from rich.console import Console from rich.table import Table diff --git a/src/smallestai/cli/auth.py b/src/smallestai/cli/auth.py index 6af63955..cdeb89d0 100644 --- a/src/smallestai/cli/auth.py +++ b/src/smallestai/cli/auth.py @@ -25,9 +25,7 @@ async def async_login(): if not api_key: if sys.stdin.isatty(): console.print("[bold cyan]Smallest API Key[/bold cyan]") - console.print( - "[dim]Enter your Smallest API key from https://app.smallest.ai/dashboard/api-keys[/dim]" - ) + console.print("[dim]Enter your Smallest API key from https://app.smallest.ai/dashboard/api-keys[/dim]") api_key = Prompt.ask("> ", password=True) else: # Piped stdin (e.g. `echo $KEY | smallestai auth login`): read directly @@ -35,9 +33,7 @@ async def async_login(): api_key = sys.stdin.readline().strip() if not api_key: - console.print( - "[red]No API key provided. Set SMALLEST_API_KEY or run in an interactive terminal.[/red]" - ) + console.print("[red]No API key provided. Set SMALLEST_API_KEY or run in an interactive terminal.[/red]") raise typer.Exit(1) try: @@ -47,9 +43,7 @@ async def async_login(): return auth_client.login(api_key) - console.print( - f"[bold green]Login successful [bold green]{account_details.userEmail}[/bold green]" - ) + console.print(f"[bold green]Login successful [bold green]{account_details.userEmail}[/bold green]") @auth_app.command() def logout(): diff --git a/src/smallestai/cli/calls.py b/src/smallestai/cli/calls.py index 64f581d6..4066c6b8 100644 --- a/src/smallestai/cli/calls.py +++ b/src/smallestai/cli/calls.py @@ -36,9 +36,7 @@ def initialise_calls_app(auth_client: AuthClient): def list_calls( agent_id: str = typer.Option(None, "--agent-id", help="Filter by agent id"), limit: int = typer.Option(20, "--limit", help="Max rows"), - call_type: str = typer.Option( - None, "--type", help="telephony_inbound | telephony_outbound | webcall" - ), + call_type: str = typer.Option(None, "--type", help="telephony_inbound | telephony_outbound | webcall"), status: str = typer.Option(None, "--status", help="Filter by status"), as_json: bool = typer.Option(False, "--json", help="Emit raw JSON"), ): @@ -106,8 +104,14 @@ def transcript( if as_json: console.print_json( _json.dumps( - [{"role": getattr(t, "role", None), "content": getattr(t, "content", None), - "timestamp": getattr(t, "timestamp", None)} for t in turns], + [ + { + "role": getattr(t, "role", None), + "content": getattr(t, "content", None), + "timestamp": getattr(t, "timestamp", None), + } + for t in turns + ], default=str, ) ) diff --git a/src/smallestai/cli/campaigns.py b/src/smallestai/cli/campaigns.py index 24882c80..724c09fd 100644 --- a/src/smallestai/cli/campaigns.py +++ b/src/smallestai/cli/campaigns.py @@ -26,9 +26,7 @@ def initialise_campaigns_app(auth_client: AuthClient): @campaigns_app.command("list") def list_campaigns( - status: str = typer.Option( - None, "--status", help="draft|scheduled|processing|running|paused|completed|failed" - ), + status: str = typer.Option(None, "--status", help="draft|scheduled|processing|running|paused|completed|failed"), search: str = typer.Option(None, "--search", help="Search by name"), page: int = typer.Option(None, "--page", help="Page number"), as_json: bool = typer.Option(False, "--json", help="Emit raw JSON"), @@ -95,8 +93,7 @@ def create_campaign( kw["scheduled_at"] = datetime.fromisoformat(scheduled_at) except ValueError: typer.echo( - f"Invalid --scheduled-at {scheduled_at!r}: expected ISO 8601, " - "e.g. 2026-08-10T15:30:00", + f"Invalid --scheduled-at {scheduled_at!r}: expected ISO 8601, e.g. 2026-08-10T15:30:00", err=True, ) raise typer.Exit(2) diff --git a/src/smallestai/cli/lib/atoms.py b/src/smallestai/cli/lib/atoms.py index 87800576..abbec48f 100644 --- a/src/smallestai/cli/lib/atoms.py +++ b/src/smallestai/cli/lib/atoms.py @@ -161,6 +161,23 @@ async def get_agents( return agents_response.data + async def get_agent_raw(self, access_token: str, agent_id: str) -> dict: + """Fetch a single agent's full config as a raw dict (for `doctor` inspection).""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/atoms/v1/agent/{agent_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + response.raise_for_status() + body = response.json() + if isinstance(body, dict) and body.get("status") is False: + raise Exception(body.get("errors")) + data = body.get("data", body) if isinstance(body, dict) else body + # some responses nest under data.agent + if isinstance(data, dict) and "agent" in data and isinstance(data["agent"], dict): + data = data["agent"] + return data if isinstance(data, dict) else {} + async def get_account_details( self, access_token: str, @@ -175,14 +192,9 @@ async def get_account_details( response.raise_for_status() - account_details_response = AccountDetailsAPIResponse.model_validate( - response.json() - ) + account_details_response = AccountDetailsAPIResponse.model_validate(response.json()) - if ( - account_details_response.status is False - or account_details_response.data is None - ): + if account_details_response.status is False or account_details_response.data is None: raise Exception(account_details_response.errors) return account_details_response.data @@ -208,14 +220,9 @@ async def create_agent_build( response.raise_for_status() - create_agent_build_response = CreateAgentBuildAPIResponse.model_validate( - response.json() - ) + create_agent_build_response = CreateAgentBuildAPIResponse.model_validate(response.json()) - if ( - create_agent_build_response.status is False - or create_agent_build_response.data is None - ): + if create_agent_build_response.status is False or create_agent_build_response.data is None: raise Exception(create_agent_build_response.errors) return create_agent_build_response.data @@ -241,14 +248,9 @@ async def list_agent_builds( response.raise_for_status() - list_builds_response = ListAgentBuildsAPIResponse.model_validate( - response.json() - ) + list_builds_response = ListAgentBuildsAPIResponse.model_validate(response.json()) - if ( - list_builds_response.status is False - or list_builds_response.data is None - ): + if list_builds_response.status is False or list_builds_response.data is None: raise Exception(list_builds_response.errors) return list_builds_response.data @@ -269,9 +271,7 @@ async def get_agent_build( response.raise_for_status() - get_build_response = GetAgentBuildAPIResponse.model_validate( - response.json() - ) + get_build_response = GetAgentBuildAPIResponse.model_validate(response.json()) if get_build_response.status is False or get_build_response.data is None: raise Exception(get_build_response.errors) @@ -298,14 +298,9 @@ async def update_agent_build( response.raise_for_status() - update_build_response = UpdateAgentBuildAPIResponse.model_validate( - response.json() - ) + update_build_response = UpdateAgentBuildAPIResponse.model_validate(response.json()) - if ( - update_build_response.status is False - or update_build_response.data is None - ): + if update_build_response.status is False or update_build_response.data is None: raise Exception(update_build_response.errors) return update_build_response.data diff --git a/src/smallestai/cli/lib/chat.py b/src/smallestai/cli/lib/chat.py index 2cec873d..af8d0424 100644 --- a/src/smallestai/cli/lib/chat.py +++ b/src/smallestai/cli/lib/chat.py @@ -122,9 +122,7 @@ class SDKAgentEvent(SDKEvent, type=EventType.AGENT_BASE.value): pass -class SDKAgentTranscriptUpdateEvent( - SDKAgentEvent, type=EventType.AGENT_TRANSCRIPT_UPDATE.value -): +class SDKAgentTranscriptUpdateEvent(SDKAgentEvent, type=EventType.AGENT_TRANSCRIPT_UPDATE.value): role: Literal["user", "assistant"] content: str @@ -166,19 +164,13 @@ class TransferOption(BaseModel): private_handoff_option: Optional[WarmTransferPrivateHandoffOption] = Field( default=None, alias="privateHandoffOption" ) - public_handoff_option: Optional[WarmTransferPublicHandoffOption] = Field( - default=None, alias="publicHandoffOption" - ) + public_handoff_option: Optional[WarmTransferPublicHandoffOption] = Field(default=None, alias="publicHandoffOption") -class SDKAgentTransferConversationEvent( - SDKAgentEvent, type=EventType.AGENT_TRANSFER_CONVERSATION.value -): +class SDKAgentTransferConversationEvent(SDKAgentEvent, type=EventType.AGENT_TRANSFER_CONVERSATION.value): transfer_call_number: str transfer_options: TransferOption - on_hold_music: Optional[ - Literal["ringtone", "relaxing_sound", "uplifting_beats", "none"] - ] + on_hold_music: Optional[Literal["ringtone", "relaxing_sound", "uplifting_beats", "none"]] class SDKSystemInitEvent(SDKSystemEvent, type=EventType.SYSTEM_INIT.value): @@ -187,21 +179,15 @@ class SDKSystemInitEvent(SDKSystemEvent, type=EventType.SYSTEM_INIT.value): output_agent_settings: Optional[OutputAgentSettings] = None -class SDKSystemUpdateOutputAgentSettingsEvent( - SDKSystemEvent, type=EventType.SYSTEM_UPDATE_OUTPUT_AGENT_SETTINGS.value -): +class SDKSystemUpdateOutputAgentSettingsEvent(SDKSystemEvent, type=EventType.SYSTEM_UPDATE_OUTPUT_AGENT_SETTINGS.value): settings: Dict[str, Any] -class SDKSystemUserStartedSpeakingEvent( - SDKSystemEvent, type=EventType.SYSTEM_USER_STARTED_SPEAKING.value -): +class SDKSystemUserStartedSpeakingEvent(SDKSystemEvent, type=EventType.SYSTEM_USER_STARTED_SPEAKING.value): pass -class SDKSystemUserStoppedSpeakingEvent( - SDKSystemEvent, type=EventType.SYSTEM_USER_STOPPED_SPEAKING.value -): +class SDKSystemUserStoppedSpeakingEvent(SDKSystemEvent, type=EventType.SYSTEM_USER_STOPPED_SPEAKING.value): pass @@ -211,9 +197,7 @@ class SDKSystemLLMRequestEvent(SDKSystemEvent, type=EventType.SYSTEM_LLM_REQUEST extra_params: Dict[str, Any] = Field(default_factory=dict) -class SDKSystemControlInterruptEvent( - SDKSystemEvent, type=EventType.SYSTEM_CONTROL_INTERRUPT.value -): +class SDKSystemControlInterruptEvent(SDKSystemEvent, type=EventType.SYSTEM_CONTROL_INTERRUPT.value): pass @@ -229,41 +213,29 @@ class SDKAgentErrorEvent(SDKAgentEvent, type=EventType.AGENT_ERROR.value): message: str -class SDKAgentLLMResponseStartEvent( - SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_START.value -): +class SDKAgentLLMResponseStartEvent(SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_START.value): """Streaming response started.""" request_id: Optional[str] = None -class SDKAgentLLMResponseChunkEvent( - SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_CHUNK.value -): +class SDKAgentLLMResponseChunkEvent(SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_CHUNK.value): text: str -class SDKAgentLLMResponseEndEvent( - SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_END.value -): +class SDKAgentLLMResponseEndEvent(SDKAgentEvent, type=EventType.AGENT_LLM_RESPONSE_END.value): pass -class SDKAgentControlInterruptEvent( - SDKAgentEvent, type=EventType.AGENT_CONTROL_INTERRUPT.value -): +class SDKAgentControlInterruptEvent(SDKAgentEvent, type=EventType.AGENT_CONTROL_INTERRUPT.value): pass -class SDKAgentControlMuteUserEvent( - SDKAgentEvent, type=EventType.AGENT_CONTROL_MUTE_USER.value -): +class SDKAgentControlMuteUserEvent(SDKAgentEvent, type=EventType.AGENT_CONTROL_MUTE_USER.value): pass -class SDKAgentControlUnmuteUserEvent( - SDKAgentEvent, type=EventType.AGENT_CONTROL_UNMUTE_USER.value -): +class SDKAgentControlUnmuteUserEvent(SDKAgentEvent, type=EventType.AGENT_CONTROL_UNMUTE_USER.value): pass @@ -344,9 +316,7 @@ async def _connect(self) -> bool: ) ) - received_event = await asyncio.wait_for( - self._websocket.recv(), timeout=10.0 - ) + received_event = await asyncio.wait_for(self._websocket.recv(), timeout=10.0) if isinstance(received_event, str): received_event = received_event.encode() received_event = self.codec.decode(received_event) @@ -356,9 +326,7 @@ async def _connect(self) -> bool: console.print(f"[red]Error: {received_event.message}[/red]") return False else: - console.print( - f"[yellow]Unexpected event: {received_event.type}[/yellow]" - ) + console.print(f"[yellow]Unexpected event: {received_event.type}[/yellow]") return False self._connected = True @@ -399,9 +367,7 @@ async def send_message(self, user_message: str) -> Optional[str]: try: await self._websocket.send( - self.codec.encode( - SDKAgentTranscriptUpdateEvent(role="user", content=user_message) - ) + self.codec.encode(SDKAgentTranscriptUpdateEvent(role="user", content=user_message)) ) await self._websocket.send(self.codec.encode(SDKSystemLLMRequestEvent())) @@ -421,9 +387,7 @@ async def send_message(self, user_message: str) -> Optional[str]: ) as live: while True: try: - message = await asyncio.wait_for( - self._websocket.recv(), timeout=60.0 - ) + message = await asyncio.wait_for(self._websocket.recv(), timeout=60.0) if isinstance(message, str): message = message.encode() event = self.codec.decode(message) @@ -476,11 +440,7 @@ async def send_message(self, user_message: str) -> Optional[str]: ) await self._websocket.send( - self.codec.encode( - SDKAgentTranscriptUpdateEvent( - role="assistant", content=full_response - ) - ) + self.codec.encode(SDKAgentTranscriptUpdateEvent(role="assistant", content=full_response)) ) return full_response @@ -522,9 +482,7 @@ async def receive_initial_message(self) -> Optional[str]: ) as live: while True: try: - msg = await asyncio.wait_for( - self._websocket.recv(), timeout=30.0 - ) + msg = await asyncio.wait_for(self._websocket.recv(), timeout=30.0) if isinstance(msg, str): msg = msg.encode() evt = self.codec.decode(msg) @@ -558,11 +516,7 @@ async def receive_initial_message(self) -> Optional[str]: ) await self._websocket.send( - self.codec.encode( - SDKAgentTranscriptUpdateEvent( - role="assistant", content=full_response - ) - ) + self.codec.encode(SDKAgentTranscriptUpdateEvent(role="assistant", content=full_response)) ) return full_response if full_response else None @@ -576,9 +530,7 @@ async def chat_loop(client: ChatClient): await client.send_event(SDKSystemUserJoinedEvent()) await client.receive_initial_message() - console.print( - "[dim]Type your message and press Enter. Type 'exit' or 'quit' to leave.[/dim]\n" - ) + console.print("[dim]Type your message and press Enter. Type 'exit' or 'quit' to leave.[/dim]\n") while True: try: diff --git a/src/smallestai/cli/lib/client.py b/src/smallestai/cli/lib/client.py index bf15e3fd..d7273152 100644 --- a/src/smallestai/cli/lib/client.py +++ b/src/smallestai/cli/lib/client.py @@ -36,8 +36,6 @@ def make_client(auth_client: AuthClient): base = base.rstrip("/") ws = base.replace("https://", "wss://").replace("http://", "ws://") - env = SmallestAIEnvironment( - atoms=f"{base}/atoms/v1", waves=base, waves_ws=ws, payment=base - ) + env = SmallestAIEnvironment(atoms=f"{base}/atoms/v1", waves=base, waves_ws=ws, payment=base) return SmallestAI(api_key=key, environment=env) return SmallestAI(api_key=key) diff --git a/src/smallestai/cli/lib/ownership.py b/src/smallestai/cli/lib/ownership.py new file mode 100644 index 00000000..36bbbf5a --- /dev/null +++ b/src/smallestai/cli/lib/ownership.py @@ -0,0 +1,70 @@ +"""Crew-vs-platform config ownership: the single source of truth for the CLI. + +A crew build takes over ONLY the LLM turn. Everything else about the call stays on +the platform agent config and is read fresh each call. This module holds that boundary +as data so `init` and `doctor` can surface it consistently. + +See AGENT_SDK_OWNERSHIP.md for the full matrix and rationale. +""" + +from __future__ import annotations + +from typing import List, Tuple + +from rich.console import Console +from rich.table import Table + +# (concern, where it is configured) +CREW_OWNS: List[Tuple[str, str]] = [ + ("System prompt / LLM messages", "crew code"), + ("Function tools (@function_tool, incl. transfer_call)", "crew code"), + ("end_call / speak() / mute / unmute", "crew code"), +] + +# (concern, platform field) +PLATFORM_OWNS: List[Tuple[str, str]] = [ + ("PII redaction", "redactionConfig.isEnabled"), + ("Voice / TTS (provider, speed, ...)", "synthesizer*"), + ("STT provider + language + switching", "transcriberType, defaultLanguage"), + ("First message", "firstMessage"), + ("Interruption / VAD / smart-turn", "allowInterruptions, smartTurnConfig"), + ("Voicemail detection / denoising", "voiceMailDetectionConfig, denoisingConfig"), + ("Session / idle timeouts", "sessionTimeoutConfig, llmIdleTimeoutConfig"), + ("Background sound / pronunciation", "backgroundSound, pronunciationDicts"), +] + +# Editing these on the dashboard for a crew agent does nothing (safe no-ops). +DASHBOARD_NO_OPS: List[str] = [ + "The Tools panel (including its transfer_call toggle)", + "The single-prompt prompt field", + "The model dropdown (the crew serves its own LLM)", +] + +SUMMARY = ( + "A crew build owns ONLY the LLM turn (prompt, messages, @function_tools, end_call, " + "speak, mute). Everything else - voice, STT, PII redaction, first message, timeouts - " + "is platform config: set it in the agent settings (dashboard or API), it takes effect " + "on the next call, no redeploy. The dashboard Tools panel and prompt field are ignored " + "for a crew agent." +) + + +def render_ownership(console: Console) -> None: + """Print the crew-vs-platform ownership tables + no-ops to the console.""" + crew = Table(title="Your crew code owns (the LLM turn)", title_style="bold cyan", show_edge=True) + crew.add_column("Concern") + crew.add_column("Configured in") + for concern, where in CREW_OWNS: + crew.add_row(concern, where) + + plat = Table(title="The platform owns (set in agent settings, no redeploy)", title_style="bold magenta") + plat.add_column("Concern") + plat.add_column("Field") + for concern, field in PLATFORM_OWNS: + plat.add_row(concern, field) + + console.print(crew) + console.print(plat) + console.print("[dim]Ignored for a crew agent (safe to leave alone on the dashboard):[/dim]") + for item in DASHBOARD_NO_OPS: + console.print(f" [dim]- {item}[/dim]") diff --git a/src/smallestai/cli/main.py b/src/smallestai/cli/main.py index ee459470..9714214c 100644 --- a/src/smallestai/cli/main.py +++ b/src/smallestai/cli/main.py @@ -2,21 +2,66 @@ import typer from rich.console import Console +from rich.table import Table from smallestai.cli.agent_crew import initialise_agent_crew_app from smallestai.cli.agents import initialise_agents_app from smallestai.cli.auth import initialise_auth_app from smallestai.cli.calls import initialise_calls_app from smallestai.cli.campaigns import initialise_campaigns_app -from smallestai.cli.phone_numbers import initialise_phone_numbers_app -from smallestai.cli.waves import initialise_waves_app from smallestai.cli.lib.atoms import AtomsAPIClient from smallestai.cli.lib.auth import AuthClient from smallestai.cli.lib.project_config import ProjectConfig +from smallestai.cli.mcp import initialise_mcp_app +from smallestai.cli.phone_numbers import initialise_phone_numbers_app +from smallestai.cli.waves import initialise_waves_app console = Console() -app = typer.Typer(help="SmallestAI CLI") +app = typer.Typer(help="SmallestAI CLI", no_args_is_help=False, rich_markup_mode="rich") + +_BANNER = r"""[bold magenta] + ███████╗███╗ ███╗ █████╗ ██╗ ██╗ ███████╗███████╗████████╗ + ██╔════╝████╗ ████║██╔══██╗██║ ██║ ██╔════╝██╔════╝╚══██╔══╝ + ███████╗██╔████╔██║███████║██║ ██║ █████╗ ███████╗ ██║ + ╚════██║██║╚██╔╝██║██╔══██║██║ ██║ ██╔══╝ ╚════██║ ██║ + ███████║██║ ╚═╝ ██║██║ ██║███████╗███████╗███████╗███████║ ██║ + ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝ ╚═╝[/bold magenta]""" + +_COMMANDS = [ + ("agent-crew", "Init, deploy, and manage crew (custom-LLM) voice agents"), + ("agents", "Create, inspect, and call voice agents"), + ("calls", "Inspect call logs, transcripts, and recordings"), + ("campaigns", "Manage outbound calling campaigns"), + ("phone-numbers", "Search, rent, and manage phone numbers"), + ("waves", "Text-to-speech, speech-to-text, and voices"), + ("mcp", "Set up the Smallest AI MCP server for Cursor / Claude"), + ("auth", "Log in and manage credentials"), +] + + +def _print_welcome() -> None: + console.print(_BANNER) + console.print(" [dim]Build, deploy, and run voice agents and speech models.[/dim]\n") + table = Table(show_header=False, box=None, padding=(0, 2, 0, 2)) + table.add_column(style="bold cyan", no_wrap=True) + table.add_column(style="white") + for name, desc in _COMMANDS: + table.add_row(name, desc) + console.print(table) + console.print( + "\n [dim]Run [bold]smallestai --help[/bold] for details, " + "or [bold]smallestai --help[/bold] for everything.[/dim]\n" + ) + + +@app.callback(invoke_without_command=True) +def _root(ctx: typer.Context) -> None: + """SmallestAI CLI.""" + if ctx.invoked_subcommand is None: + _print_welcome() + raise typer.Exit() + auth_client = AuthClient() atoms_client = AtomsAPIClient() @@ -43,8 +88,18 @@ phone_numbers_app = initialise_phone_numbers_app(auth_client) app.add_typer(phone_numbers_app, name="phone-numbers") +app.add_typer(initialise_mcp_app(), name="mcp") + def main(): + import sys + + from smallestai import telemetry + + telemetry.maybe_show_first_run_notice() + # Coarse command group only (e.g. "agent-crew", "auth"); never args or their values. + command = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") else "" + telemetry.capture("cli_invoked", {"command": command}) app() diff --git a/src/smallestai/cli/mcp.py b/src/smallestai/cli/mcp.py new file mode 100644 index 00000000..bff761f7 --- /dev/null +++ b/src/smallestai/cli/mcp.py @@ -0,0 +1,90 @@ +"""`smallestai mcp` - set up or run the Smallest AI MCP server. + +The MCP server (npm ``@developer-smallestai/smallest-mcp-server``) gives Cursor, Claude, +and other MCP clients native access to the Smallest AI platform. It runs on Node via npx; +this command prints the client config and can launch it for you. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess + +import typer +from rich.console import Console +from rich.syntax import Syntax + +console = Console() + +_NPM_PKG = "@developer-smallestai/smallest-mcp-server" +_SERVER_KEY = "smallest" +_ENV_VAR = "ATOMS_API_KEY" + + +def _config_snippet() -> str: + return json.dumps( + { + "mcpServers": { + _SERVER_KEY: { + "command": "npx", + "args": ["-y", _NPM_PKG], + "env": {_ENV_VAR: "sk_your_api_key_here"}, + } + } + }, + indent=2, + ) + + +def initialise_mcp_app() -> typer.Typer: + app = typer.Typer( + name="mcp", + help="Set up or run the Smallest AI MCP server (for Cursor, Claude, etc.).", + no_args_is_help=False, + ) + + @app.callback(invoke_without_command=True) + def _root(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is not None: + return + console.print( + "[bold]Smallest AI MCP server[/bold] gives Cursor, Claude, and other MCP " + "clients native access to the Smallest AI platform.\n" + ) + console.print(f"[dim]Requires Node.js 18+ and an {_ENV_VAR}.[/dim]\n") + console.print("Claude Code:") + console.print( + f" [cyan]claude mcp add {_SERVER_KEY} -- npx -y {_NPM_PKG}[/cyan]\n" + f" [cyan]claude mcp update {_SERVER_KEY} --env {_ENV_VAR}=sk_your_api_key_here[/cyan]\n" + ) + console.print("Cursor / Claude Desktop - add to your mcp.json:") + console.print(Syntax(_config_snippet(), "json", theme="ansi_dark")) + console.print("\n [dim]Or run it directly: [bold]smallestai mcp run[/bold][/dim]") + + @app.command("run") + def run() -> None: + """Run the MCP server locally via npx (Node.js 18+ required).""" + if not shutil.which("npx"): + console.print("[red]npx not found. Install Node.js 18+ from https://nodejs.org.[/red]") + raise typer.Exit(1) + # The MCP server reads ATOMS_API_KEY; bridge from the SDK's SMALLEST_API_KEY if set. + env = dict(os.environ) + api_key = env.get(_ENV_VAR) or env.get("SMALLEST_API_KEY") + if not api_key: + console.print(f"[yellow]{_ENV_VAR} is not set; the MCP server needs it.[/yellow]") + else: + env[_ENV_VAR] = api_key + console.print(f"[dim]Launching {_NPM_PKG} via npx (Ctrl-C to stop)...[/dim]") + try: + subprocess.run(["npx", "-y", _NPM_PKG], check=False, env=env) + except KeyboardInterrupt: + pass + + @app.command("config") + def config() -> None: + """Print the mcp.json config snippet.""" + console.print(Syntax(_config_snippet(), "json", theme="ansi_dark")) + + return app diff --git a/src/smallestai/cli/utils.py b/src/smallestai/cli/utils.py index 147aa0ba..c2b7d025 100644 --- a/src/smallestai/cli/utils.py +++ b/src/smallestai/cli/utils.py @@ -3,9 +3,8 @@ import io import zipfile from pathlib import Path -from typing import List, Set +from typing import Set -from loguru import logger from rich.console import Console console = Console() @@ -223,9 +222,7 @@ def should_exclude(path: Path, relative_path: Path) -> bool: return True for pattern in excluded_patterns: - if fnmatch.fnmatch(path_str, pattern) or fnmatch.fnmatch( - path_str, f"*/{pattern}" - ): + if fnmatch.fnmatch(path_str, pattern) or fnmatch.fnmatch(path_str, f"*/{pattern}"): return True if fnmatch.fnmatch(path.name, pattern): diff --git a/src/smallestai/cli/waves.py b/src/smallestai/cli/waves.py index 559c52ff..eecf9ddd 100644 --- a/src/smallestai/cli/waves.py +++ b/src/smallestai/cli/waves.py @@ -37,7 +37,9 @@ def voices( # NB: the get-voices endpoint uses the hyphen form (lightning-v3.1); the # tts endpoint uses the underscore form (lightning_v3.1). They are # distinct API enums, not a typo — each command defaults to its own. - model: str = typer.Option("lightning-v3.1", "--model", help="Voice model (get-voices form, e.g. lightning-v3.1)"), + model: str = typer.Option( + "lightning-v3.1", "--model", help="Voice model (get-voices form, e.g. lightning-v3.1)" + ), as_json: bool = typer.Option(False, "--json", help="Emit raw JSON"), ): """List available voices for a model.""" @@ -86,10 +88,10 @@ def tts( text: str = typer.Argument(..., help="Text to synthesize"), voice_id: str = typer.Option(..., "--voice-id", help="Voice id (see `waves voices`)"), out: str = typer.Option("out.wav", "--out", "-o", help="Output audio file"), - model: str = typer.Option("lightning_v3.1", "--model", help="TTS model (tts form, e.g. lightning_v3.1 / lightning_v3.1_pro)"), - output_format: str = typer.Option( - "wav", "--format", help="Audio format: wav | mp3 | pcm | ulaw | alaw" + model: str = typer.Option( + "lightning_v3.1", "--model", help="TTS model (tts form, e.g. lightning_v3.1 / lightning_v3.1_pro)" ), + output_format: str = typer.Option("wav", "--format", help="Audio format: wav | mp3 | pcm | ulaw | alaw"), sample_rate: int = typer.Option(None, "--sample-rate", help="Sample rate (Hz)"), speed: float = typer.Option(None, "--speed", help="Speech speed multiplier"), ): @@ -129,11 +131,7 @@ def stt( if as_json: console.print_json(resp.json() if hasattr(resp, "json") else _json.dumps(resp, default=str)) return - text = ( - getattr(resp, "transcription", None) - or getattr(resp, "text", None) - or getattr(resp, "transcript", None) - ) + text = getattr(resp, "transcription", None) or getattr(resp, "text", None) or getattr(resp, "transcript", None) console.print(text if text is not None else resp) return waves_app diff --git a/src/smallestai/core/_error_hints.py b/src/smallestai/core/_error_hints.py index bdf6e31e..781a96ee 100644 --- a/src/smallestai/core/_error_hints.py +++ b/src/smallestai/core/_error_hints.py @@ -68,9 +68,6 @@ def hint_for(status_code: typing.Optional[int], body: typing.Any) -> str: ) if status_code == 403: if _looks_org_gated(body): - return ( - "This feature is limited to specific organizations. " - "Contact your account team to enable it." - ) + return "This feature is limited to specific organizations. Contact your account team to enable it." return "Your API key is valid but is not permitted to perform this action." return "" diff --git a/src/smallestai/telemetry.py b/src/smallestai/telemetry.py new file mode 100644 index 00000000..df6f6ddd --- /dev/null +++ b/src/smallestai/telemetry.py @@ -0,0 +1,136 @@ +"""Anonymous, opt-out usage telemetry. + +Sends lightweight product events (CLI usage, deploy outcomes) to PostHog so we can see +what to improve. It is deliberately minimal and privacy-first: + +- NO personal data or secrets: never an API key, agent id, prompt, transcript, phone + number, file path, or error message. Only the event name, SDK / Python / OS version, + and a random anonymous install id. +- Geography (country) is derived server-side by PostHog from the request IP; no location + is ever collected client-side. +- Fire-and-forget on a daemon thread. It never blocks and never raises. + +Opt out any time: + + export SMALLESTAI_TELEMETRY=0 # or DO_NOT_TRACK=1 +""" + +from __future__ import annotations + +import json +import os +import platform +import threading +import uuid +from pathlib import Path +from typing import Any, Dict, Optional + +# PostHog project (US cloud). The phc_ key is write-only and safe to ship in a client. +_POSTHOG_HOST = "https://us.i.posthog.com" +_PROJECT_KEY = "phc_vqBpz5cQHFjsmfFix6KuCjf8iwtH2hJaeReHPSq46PyH" + +_OFF_VALUES = {"0", "false", "no", "off"} +_ON_VALUES = {"1", "true", "yes", "on"} + + +def is_enabled() -> bool: + """Telemetry is on by default; disabled by SMALLESTAI_TELEMETRY or DO_NOT_TRACK.""" + if os.getenv("SMALLESTAI_TELEMETRY", "").strip().lower() in _OFF_VALUES: + return False + if os.getenv("DO_NOT_TRACK", "").strip().lower() in _ON_VALUES: + return False + return True + + +def _config_dir() -> Path: + base = os.getenv("XDG_CONFIG_HOME") or os.path.join(str(Path.home()), ".config") + return Path(base) / "smallestai" + + +def _state_file() -> Path: + return _config_dir() / "telemetry.json" + + +def _read_state() -> Dict[str, Any]: + try: + return json.loads(_state_file().read_text()) + except Exception: + return {} + + +def _write_state(state: Dict[str, Any]) -> None: + try: + _config_dir().mkdir(parents=True, exist_ok=True) + _state_file().write_text(json.dumps(state)) + except Exception: + pass + + +def install_id() -> str: + """A random anonymous id, persisted once per install. Not tied to any account.""" + state = _read_state() + iid = state.get("install_id") + if not iid: + iid = uuid.uuid4().hex + state["install_id"] = iid + _write_state(state) + return iid + + +def _sdk_version() -> str: + try: + from smallestai.version import __version__ + + return __version__ + except Exception: + return "unknown" + + +def _base_properties() -> Dict[str, Any]: + return { + "sdk_version": _sdk_version(), + "python_version": platform.python_version(), + "os": platform.system(), + } + + +def maybe_show_first_run_notice() -> None: + """Print a one-time notice about anonymous telemetry (only if enabled).""" + if not is_enabled(): + return + state = _read_state() + if state.get("notice_shown"): + return + state["notice_shown"] = True + _write_state(state) + try: + print( + "smallestai collects anonymous usage telemetry to improve the SDK " + "(no personal data or secrets). Opt out with SMALLESTAI_TELEMETRY=0.", + flush=True, + ) + except Exception: + pass + + +def _post(event: str, properties: Dict[str, Any]) -> None: + try: + import httpx + + payload = { + "api_key": _PROJECT_KEY, + "event": event, + "distinct_id": install_id(), + "properties": properties, + } + httpx.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=2.0) + except Exception: + pass # telemetry must never affect the caller + + +def capture(event: str, properties: Optional[Dict[str, Any]] = None) -> None: + """Fire-and-forget an anonymous event. Never blocks, never raises, respects opt-out.""" + if not is_enabled(): + return + props = {**_base_properties(), **(properties or {})} + threading.Thread(target=_post, args=(event, props), daemon=True).start() diff --git a/src/smallestai/tools/__init__.py b/src/smallestai/tools/__init__.py new file mode 100644 index 00000000..e3491806 --- /dev/null +++ b/src/smallestai/tools/__init__.py @@ -0,0 +1,61 @@ +"""Prebuilt, pluggable tools for crew agents. + +Each tool is a small class wrapping a third-party capability (web search, etc.) with a +``@function_tool``-decorated ``run`` method, so it drops straight into a crew's +``ToolRegistry`` and can also be called directly. Third-party libraries are optional +extras, lazy-imported on first use with a clear "install the extra" error. + + from smallestai.tools import ExaSearchTool + + search = ExaSearchTool() # reads EXA_API_KEY + # inside a crew node: + search.register(self.tool_registry) + # or standalone: + results = await search.run(query="latest news on voice AI") + +Discover what's available: + + from smallestai.tools import list_tools, get_tool + list_tools() # {"exa_search": ExaSearchTool, ...} +""" + +from __future__ import annotations + +import importlib +from typing import Dict, Type + +from smallestai.tools.base import Tool + +# name -> "module:ClassName". Lazy so importing this package never pulls a third-party lib. +_REGISTRY: Dict[str, str] = { + "exa_search": "smallestai.tools.exa:ExaSearchTool", +} + + +def list_tools() -> Dict[str, Type[Tool]]: + """Return every available tool as ``{name: class}`` (imports each tool module).""" + out: Dict[str, Type[Tool]] = {} + for name in _REGISTRY: + out[name] = get_tool(name) + return out + + +def get_tool(name: str) -> Type[Tool]: + """Return a tool class by registry name (e.g. ``"exa_search"``).""" + try: + path = _REGISTRY[name] + except KeyError as exc: + raise KeyError(f"Unknown tool {name!r}. Available: {sorted(_REGISTRY)}") from exc + module_path, _, class_name = path.partition(":") + module = importlib.import_module(module_path) + return getattr(module, class_name) + + +def __getattr__(name: str): # PEP 562: expose tool classes lazily at package level + for reg_name, path in _REGISTRY.items(): + if path.endswith(":" + name): + return get_tool(reg_name) + raise AttributeError(f"module 'smallestai.tools' has no attribute {name!r}") + + +__all__ = ["Tool", "list_tools", "get_tool", "ExaSearchTool"] diff --git a/src/smallestai/tools/base.py b/src/smallestai/tools/base.py new file mode 100644 index 00000000..d251a9f7 --- /dev/null +++ b/src/smallestai/tools/base.py @@ -0,0 +1,34 @@ +"""Base class for prebuilt crew tools.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from smallestai.atoms.crew.tools import ToolRegistry + + +class Tool: + """A prebuilt tool wrapping a third-party capability. + + Subclasses implement an async ``run`` method decorated with ``@function_tool`` (so the + crew can auto-extract its schema and the LLM can call it), and set ``name`` / + ``description``. ``run`` stays directly callable for standalone use. + """ + + name: str = "" + description: str = "" + + def register(self, registry: "ToolRegistry") -> None: + """Add this tool's ``run`` to a crew ``ToolRegistry`` so the agent's LLM can call it. + + search = ExaSearchTool() + search.register(self.tool_registry) + """ + run = getattr(self, "run", None) + if run is None or not hasattr(run, "__tool_info__"): + raise TypeError( + f"{type(self).__name__}.run must be decorated with @function_tool to be " + "registered with a crew ToolRegistry." + ) + registry.register(run) diff --git a/src/smallestai/tools/exa.py b/src/smallestai/tools/exa.py new file mode 100644 index 00000000..9b8ea0f6 --- /dev/null +++ b/src/smallestai/tools/exa.py @@ -0,0 +1,64 @@ +"""Exa web-search tool for crew agents. + + from smallestai.tools import ExaSearchTool + search = ExaSearchTool() # reads EXA_API_KEY + search.register(self.tool_registry) # inside a crew node + +Requires the exa extra: pip install "smallestai[exa]" +""" + +from __future__ import annotations + +import asyncio +import os +import typing + +from smallestai.atoms.crew.tools import function_tool +from smallestai.tools.base import Tool + +_INSTALL_HINT = 'pip install "smallestai[exa]"' + + +class ExaSearchTool(Tool): + name = "exa_search" + description = "Search the web for current information using Exa." + + def __init__(self, api_key: typing.Optional[str] = None) -> None: + self._api_key = api_key or os.getenv("EXA_API_KEY") + self._client: typing.Any = None + + def _get_client(self) -> typing.Any: + if self._client is not None: + return self._client + try: + from exa_py import Exa # type: ignore[import-not-found] + except ImportError as exc: + raise ImportError( + "ExaSearchTool requires the exa-py package. Install it with:\n " + _INSTALL_HINT + ) from exc + if not self._api_key: + raise ValueError("No Exa API key. Pass api_key=... or set the EXA_API_KEY env var.") + self._client = Exa(self._api_key) + return self._client + + @function_tool(name="web_search") + async def run(self, query: str, num_results: int = 3) -> str: + """Search the web for up-to-date information and return the top results. + + Args: + query: What to search the web for. + num_results: How many results to return (default 3). + """ + client = self._get_client() + # exa-py is synchronous; run it off the event loop so we don't block the call. + response = await asyncio.to_thread(client.search_and_contents, query, num_results=num_results, text=True) + results = getattr(response, "results", None) or [] + if not results: + return f"No results found for {query!r}." + lines = [] + for r in results: + title = getattr(r, "title", "") or "" + url = getattr(r, "url", "") or "" + text = (getattr(r, "text", "") or "")[:500].strip() + lines.append("\n".join(part for part in (f"- {title}", f" {url}", f" {text}") if part.strip())) + return "\n".join(lines) diff --git a/src/smallestai/waves/__init__.py b/src/smallestai/waves/__init__.py index 3842e4ea..d711d9a1 100644 --- a/src/smallestai/waves/__init__.py +++ b/src/smallestai/waves/__init__.py @@ -6,6 +6,37 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .types import CatalogVoice + from .types import CatalogVoiceTags + from .types import CountTimeseriesResponse + from .types import CreditsTimeseriesResponse + from .analytics import DeleteStreamingSpeechToTextHistoryResponse + from .types import DispositionMetric + from .types import DispositionMetricDispositionMetricType + from .post_call_analysis import GeneratePostCallAnalysisResponse + from .voices import GetAllVoiceModelsResponse + from .analytics import GetStreamingSpeechToTextUsageTimeseriesRequestGranularity + from .analytics import GetTextToSpeechConcurrencyTimeseriesRequestGranularity + from .analytics import GetTextToSpeechCreditsTimeseriesRequestGranularity + from .analytics import GetTextToSpeechUsageTimeseriesRequestGranularity + from .analytics import GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity + from .types import HealthResponse + from .types import PcaResponse + from .types import PcaResponseDispositionMetricsItem + from .types import ServiceStatus + from .types import StreamingSpeechToTextLogEntry + from .types import StreamingSpeechToTextLogEntryRequestType + from .types import StreamingSpeechToTextLogsResponse + from .types import TextToSpeechLogEntry + from .types import TextToSpeechLogEntryRequestType + from .types import TextToSpeechLogsResponse + from .types import ValuesTimeseriesResponse + from .types import WebhookLogEntry + from .types import WebhookLogsResponse + from . import analytics + from . import ops + from . import post_call_analysis + from . import voices from .types import ( AsyncAccepted, AudioChunk, @@ -98,6 +129,7 @@ UnauthorizedError, ) from . import electron, speech_to_speech, speech_to_text, streaming_tts, tts + # Backward-compat shim for the 4.3.1 surface (source in stream_tts.py, .fernignore'd). from .stream_tts import TTSConfig, WavesStreamingTTS from .electron import ( @@ -202,6 +234,37 @@ TtsResponseMessageStatus, ) _dynamic_imports: typing.Dict[str, str] = { + "CatalogVoice": ".types", + "CatalogVoiceTags": ".types", + "CountTimeseriesResponse": ".types", + "CreditsTimeseriesResponse": ".types", + "DeleteStreamingSpeechToTextHistoryResponse": ".analytics", + "DispositionMetric": ".types", + "DispositionMetricDispositionMetricType": ".types", + "GeneratePostCallAnalysisResponse": ".post_call_analysis", + "GetAllVoiceModelsResponse": ".voices", + "GetStreamingSpeechToTextUsageTimeseriesRequestGranularity": ".analytics", + "GetTextToSpeechConcurrencyTimeseriesRequestGranularity": ".analytics", + "GetTextToSpeechCreditsTimeseriesRequestGranularity": ".analytics", + "GetTextToSpeechUsageTimeseriesRequestGranularity": ".analytics", + "GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity": ".analytics", + "HealthResponse": ".types", + "PcaResponse": ".types", + "PcaResponseDispositionMetricsItem": ".types", + "ServiceStatus": ".types", + "StreamingSpeechToTextLogEntry": ".types", + "StreamingSpeechToTextLogEntryRequestType": ".types", + "StreamingSpeechToTextLogsResponse": ".types", + "TextToSpeechLogEntry": ".types", + "TextToSpeechLogEntryRequestType": ".types", + "TextToSpeechLogsResponse": ".types", + "ValuesTimeseriesResponse": ".types", + "WebhookLogEntry": ".types", + "WebhookLogsResponse": ".types", + "analytics": ".analytics", + "ops": ".ops", + "post_call_analysis": ".post_call_analysis", + "voices": ".voices", "WavesStreamingTTS": ".stream_tts", "TTSConfig": ".stream_tts", "AsyncAccepted": ".types", @@ -412,6 +475,37 @@ def __dir__(): __all__ = [ + "CatalogVoice", + "CatalogVoiceTags", + "CountTimeseriesResponse", + "CreditsTimeseriesResponse", + "DeleteStreamingSpeechToTextHistoryResponse", + "DispositionMetric", + "DispositionMetricDispositionMetricType", + "GeneratePostCallAnalysisResponse", + "GetAllVoiceModelsResponse", + "GetStreamingSpeechToTextUsageTimeseriesRequestGranularity", + "GetTextToSpeechConcurrencyTimeseriesRequestGranularity", + "GetTextToSpeechCreditsTimeseriesRequestGranularity", + "GetTextToSpeechUsageTimeseriesRequestGranularity", + "GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity", + "HealthResponse", + "PcaResponse", + "PcaResponseDispositionMetricsItem", + "ServiceStatus", + "StreamingSpeechToTextLogEntry", + "StreamingSpeechToTextLogEntryRequestType", + "StreamingSpeechToTextLogsResponse", + "TextToSpeechLogEntry", + "TextToSpeechLogEntryRequestType", + "TextToSpeechLogsResponse", + "ValuesTimeseriesResponse", + "WebhookLogEntry", + "WebhookLogsResponse", + "analytics", + "ops", + "post_call_analysis", + "voices", "TTSConfig", "WavesStreamingTTS", "AsyncAccepted", diff --git a/src/smallestai/waves/analytics/__init__.py b/src/smallestai/waves/analytics/__init__.py new file mode 100644 index 00000000..a8d26f49 --- /dev/null +++ b/src/smallestai/waves/analytics/__init__.py @@ -0,0 +1,55 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + DeleteStreamingSpeechToTextHistoryResponse, + GetStreamingSpeechToTextUsageTimeseriesRequestGranularity, + GetTextToSpeechConcurrencyTimeseriesRequestGranularity, + GetTextToSpeechCreditsTimeseriesRequestGranularity, + GetTextToSpeechUsageTimeseriesRequestGranularity, + GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity, + ) +_dynamic_imports: typing.Dict[str, str] = { + "DeleteStreamingSpeechToTextHistoryResponse": ".types", + "GetStreamingSpeechToTextUsageTimeseriesRequestGranularity": ".types", + "GetTextToSpeechConcurrencyTimeseriesRequestGranularity": ".types", + "GetTextToSpeechCreditsTimeseriesRequestGranularity": ".types", + "GetTextToSpeechUsageTimeseriesRequestGranularity": ".types", + "GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "DeleteStreamingSpeechToTextHistoryResponse", + "GetStreamingSpeechToTextUsageTimeseriesRequestGranularity", + "GetTextToSpeechConcurrencyTimeseriesRequestGranularity", + "GetTextToSpeechCreditsTimeseriesRequestGranularity", + "GetTextToSpeechUsageTimeseriesRequestGranularity", + "GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity", +] diff --git a/src/smallestai/waves/analytics/client.py b/src/smallestai/waves/analytics/client.py new file mode 100644 index 00000000..9d8b00dc --- /dev/null +++ b/src/smallestai/waves/analytics/client.py @@ -0,0 +1,991 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from ..types.count_timeseries_response import CountTimeseriesResponse +from ..types.credits_timeseries_response import CreditsTimeseriesResponse +from ..types.streaming_speech_to_text_logs_response import StreamingSpeechToTextLogsResponse +from ..types.text_to_speech_logs_response import TextToSpeechLogsResponse +from ..types.values_timeseries_response import ValuesTimeseriesResponse +from ..types.webhook_logs_response import WebhookLogsResponse +from .raw_client import AsyncRawAnalyticsClient, RawAnalyticsClient +from .types.delete_streaming_speech_to_text_history_response import DeleteStreamingSpeechToTextHistoryResponse +from .types.get_streaming_speech_to_text_usage_timeseries_request_granularity import ( + GetStreamingSpeechToTextUsageTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_concurrency_timeseries_request_granularity import ( + GetTextToSpeechConcurrencyTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_credits_timeseries_request_granularity import ( + GetTextToSpeechCreditsTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_usage_timeseries_request_granularity import ( + GetTextToSpeechUsageTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_websocket_connections_timeseries_request_granularity import ( + GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity, +) + + +class AnalyticsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawAnalyticsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawAnalyticsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawAnalyticsClient + """ + return self._raw_client + + def list_streaming_speech_to_text_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> StreamingSpeechToTextLogsResponse: + """ + Paginated log of Speech to Text requests for the calling organization. Each entry + records the model used, the transcript, the request parameters that were on the + wire (word timestamps, diarization, PII / PCI redaction, keyword boosting), and + any downstream signals the request opted into (emotion, gender, keyword hits). + Use this to audit request-level activity or to reconcile with your own logs. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + StreamingSpeechToTextLogsResponse + STT logs retrieved successfully. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.list_streaming_speech_to_text_logs() + """ + _response = self._raw_client.list_streaming_speech_to_text_logs( + page=page, page_size=page_size, request_options=request_options + ) + return _response.data + + def delete_streaming_speech_to_text_history( + self, request_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> DeleteStreamingSpeechToTextHistoryResponse: + """ + Soft-deletes a single Speech to Text request from the org's analytics history. + Idempotent: a `request_id` that does not exist still returns `success`. Use this + to redact a specific transcript from the dashboard log view. + + Parameters + ---------- + request_id : str + The `request_id` of the STT request to delete. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeleteStreamingSpeechToTextHistoryResponse + History entry deleted (or was already absent). + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.delete_streaming_speech_to_text_history( + request_id="3eea9859-609b-45c5-8a25-0337c9763c96", + ) + """ + _response = self._raw_client.delete_streaming_speech_to_text_history( + request_id, request_options=request_options + ) + return _response.data + + def get_streaming_speech_to_text_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> CountTimeseriesResponse: + """ + Bucketed request count for Speech to Text over a datetime range. Use for a + traffic chart on the STT dashboard, or to reconcile daily usage. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CountTimeseriesResponse + Timeseries retrieved successfully. + + Examples + -------- + import datetime + + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.get_streaming_speech_to_text_usage_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + """ + _response = self._raw_client.get_streaming_speech_to_text_usage_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + def list_text_to_speech_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> TextToSpeechLogsResponse: + """ + Paginated log of Text to Speech requests. Each entry records the model, voice, + input text length, sample rate, output format, and credits consumed. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TextToSpeechLogsResponse + TTS logs retrieved successfully. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.list_text_to_speech_logs() + """ + _response = self._raw_client.list_text_to_speech_logs( + page=page, page_size=page_size, request_options=request_options + ) + return _response.data + + def get_text_to_speech_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> CountTimeseriesResponse: + """ + Bucketed request count for Text to Speech over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CountTimeseriesResponse + Timeseries retrieved successfully. + + Examples + -------- + import datetime + + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.get_text_to_speech_usage_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + """ + _response = self._raw_client.get_text_to_speech_usage_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + def get_text_to_speech_credits_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreditsTimeseriesResponse: + """ + Bucketed credit spend for Text to Speech over a datetime range. Use this to + forecast spend or reconcile bills. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreditsTimeseriesResponse + Credit timeseries retrieved successfully. + + Examples + -------- + import datetime + + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.get_text_to_speech_credits_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + """ + _response = self._raw_client.get_text_to_speech_credits_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + def get_text_to_speech_concurrency_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ValuesTimeseriesResponse: + """ + Bucketed peak concurrency (simultaneous active TTS requests) over a datetime + range. Use to size against your account concurrency cap. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ValuesTimeseriesResponse + Concurrency timeseries retrieved successfully. + + Examples + -------- + import datetime + + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.get_text_to_speech_concurrency_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + """ + _response = self._raw_client.get_text_to_speech_concurrency_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + def get_text_to_speech_websocket_connections_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ValuesTimeseriesResponse: + """ + Bucketed count of open TTS WebSocket connections over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ValuesTimeseriesResponse + WebSocket-connection timeseries retrieved successfully. + + Examples + -------- + import datetime + + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.get_text_to_speech_websocket_connections_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + """ + _response = self._raw_client.get_text_to_speech_websocket_connections_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + def list_webhook_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> WebhookLogsResponse: + """ + Paginated log of webhook delivery attempts (e.g. `asr.completed` callbacks). + Each entry records the destination URL, HTTP method, event type, delivery + status, attempt number, response status code, and any error. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebhookLogsResponse + Webhook logs retrieved successfully. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.analytics.list_webhook_logs() + """ + _response = self._raw_client.list_webhook_logs(page=page, page_size=page_size, request_options=request_options) + return _response.data + + +class AsyncAnalyticsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawAnalyticsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawAnalyticsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawAnalyticsClient + """ + return self._raw_client + + async def list_streaming_speech_to_text_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> StreamingSpeechToTextLogsResponse: + """ + Paginated log of Speech to Text requests for the calling organization. Each entry + records the model used, the transcript, the request parameters that were on the + wire (word timestamps, diarization, PII / PCI redaction, keyword boosting), and + any downstream signals the request opted into (emotion, gender, keyword hits). + Use this to audit request-level activity or to reconcile with your own logs. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + StreamingSpeechToTextLogsResponse + STT logs retrieved successfully. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.list_streaming_speech_to_text_logs() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_streaming_speech_to_text_logs( + page=page, page_size=page_size, request_options=request_options + ) + return _response.data + + async def delete_streaming_speech_to_text_history( + self, request_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> DeleteStreamingSpeechToTextHistoryResponse: + """ + Soft-deletes a single Speech to Text request from the org's analytics history. + Idempotent: a `request_id` that does not exist still returns `success`. Use this + to redact a specific transcript from the dashboard log view. + + Parameters + ---------- + request_id : str + The `request_id` of the STT request to delete. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeleteStreamingSpeechToTextHistoryResponse + History entry deleted (or was already absent). + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.delete_streaming_speech_to_text_history( + request_id="3eea9859-609b-45c5-8a25-0337c9763c96", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.delete_streaming_speech_to_text_history( + request_id, request_options=request_options + ) + return _response.data + + async def get_streaming_speech_to_text_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> CountTimeseriesResponse: + """ + Bucketed request count for Speech to Text over a datetime range. Use for a + traffic chart on the STT dashboard, or to reconcile daily usage. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CountTimeseriesResponse + Timeseries retrieved successfully. + + Examples + -------- + import asyncio + import datetime + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.get_streaming_speech_to_text_usage_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_streaming_speech_to_text_usage_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + async def list_text_to_speech_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> TextToSpeechLogsResponse: + """ + Paginated log of Text to Speech requests. Each entry records the model, voice, + input text length, sample rate, output format, and credits consumed. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TextToSpeechLogsResponse + TTS logs retrieved successfully. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.list_text_to_speech_logs() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_text_to_speech_logs( + page=page, page_size=page_size, request_options=request_options + ) + return _response.data + + async def get_text_to_speech_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> CountTimeseriesResponse: + """ + Bucketed request count for Text to Speech over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CountTimeseriesResponse + Timeseries retrieved successfully. + + Examples + -------- + import asyncio + import datetime + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.get_text_to_speech_usage_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_text_to_speech_usage_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + async def get_text_to_speech_credits_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreditsTimeseriesResponse: + """ + Bucketed credit spend for Text to Speech over a datetime range. Use this to + forecast spend or reconcile bills. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreditsTimeseriesResponse + Credit timeseries retrieved successfully. + + Examples + -------- + import asyncio + import datetime + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.get_text_to_speech_credits_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_text_to_speech_credits_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + async def get_text_to_speech_concurrency_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ValuesTimeseriesResponse: + """ + Bucketed peak concurrency (simultaneous active TTS requests) over a datetime + range. Use to size against your account concurrency cap. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ValuesTimeseriesResponse + Concurrency timeseries retrieved successfully. + + Examples + -------- + import asyncio + import datetime + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.get_text_to_speech_concurrency_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_text_to_speech_concurrency_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + async def get_text_to_speech_websocket_connections_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ValuesTimeseriesResponse: + """ + Bucketed count of open TTS WebSocket connections over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ValuesTimeseriesResponse + WebSocket-connection timeseries retrieved successfully. + + Examples + -------- + import asyncio + import datetime + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.get_text_to_speech_websocket_connections_timeseries( + from_=datetime.datetime.fromisoformat( + "2026-08-01 00:00:00+00:00", + ), + to=datetime.datetime.fromisoformat( + "2026-08-07 00:00:00+00:00", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_text_to_speech_websocket_connections_timeseries( + from_=from_, to=to, granularity=granularity, request_options=request_options + ) + return _response.data + + async def list_webhook_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> WebhookLogsResponse: + """ + Paginated log of webhook delivery attempts (e.g. `asr.completed` callbacks). + Each entry records the destination URL, HTTP method, event type, delivery + status, attempt number, response status code, and any error. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + WebhookLogsResponse + Webhook logs retrieved successfully. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.analytics.list_webhook_logs() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_webhook_logs( + page=page, page_size=page_size, request_options=request_options + ) + return _response.data diff --git a/src/smallestai/waves/analytics/raw_client.py b/src/smallestai/waves/analytics/raw_client.py new file mode 100644 index 00000000..3fd89bf2 --- /dev/null +++ b/src/smallestai/waves/analytics/raw_client.py @@ -0,0 +1,1487 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.datetime_utils import serialize_datetime +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.jsonable_encoder import encode_path_param +from ...core.parse_error import ParsingError +from ...core.request_options import RequestOptions +from ...core.unchecked_base_model import construct_type +from ..errors.internal_server_error import InternalServerError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.count_timeseries_response import CountTimeseriesResponse +from ..types.credits_timeseries_response import CreditsTimeseriesResponse +from ..types.streaming_speech_to_text_logs_response import StreamingSpeechToTextLogsResponse +from ..types.text_to_speech_logs_response import TextToSpeechLogsResponse +from ..types.values_timeseries_response import ValuesTimeseriesResponse +from ..types.webhook_logs_response import WebhookLogsResponse +from .types.delete_streaming_speech_to_text_history_response import DeleteStreamingSpeechToTextHistoryResponse +from .types.get_streaming_speech_to_text_usage_timeseries_request_granularity import ( + GetStreamingSpeechToTextUsageTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_concurrency_timeseries_request_granularity import ( + GetTextToSpeechConcurrencyTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_credits_timeseries_request_granularity import ( + GetTextToSpeechCreditsTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_usage_timeseries_request_granularity import ( + GetTextToSpeechUsageTimeseriesRequestGranularity, +) +from .types.get_text_to_speech_websocket_connections_timeseries_request_granularity import ( + GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity, +) +from pydantic import ValidationError + + +class RawAnalyticsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list_streaming_speech_to_text_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[StreamingSpeechToTextLogsResponse]: + """ + Paginated log of Speech to Text requests for the calling organization. Each entry + records the model used, the transcript, the request parameters that were on the + wire (word timestamps, diarization, PII / PCI redaction, keyword boosting), and + any downstream signals the request opted into (emotion, gender, keyword hits). + Use this to audit request-level activity or to reconcile with your own logs. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[StreamingSpeechToTextLogsResponse] + STT logs retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/asr/logs", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "page": page, + "pageSize": page_size, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + StreamingSpeechToTextLogsResponse, + construct_type( + type_=StreamingSpeechToTextLogsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def delete_streaming_speech_to_text_history( + self, request_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[DeleteStreamingSpeechToTextHistoryResponse]: + """ + Soft-deletes a single Speech to Text request from the org's analytics history. + Idempotent: a `request_id` that does not exist still returns `success`. Use this + to redact a specific transcript from the dashboard log view. + + Parameters + ---------- + request_id : str + The `request_id` of the STT request to delete. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[DeleteStreamingSpeechToTextHistoryResponse] + History entry deleted (or was already absent). + """ + _response = self._client_wrapper.httpx_client.request( + f"waves/v1/analytics/asr/history/{encode_path_param(request_id)}", + base_url=self._client_wrapper.get_environment().atoms, + method="DELETE", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeleteStreamingSpeechToTextHistoryResponse, + construct_type( + type_=DeleteStreamingSpeechToTextHistoryResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def get_streaming_speech_to_text_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[CountTimeseriesResponse]: + """ + Bucketed request count for Speech to Text over a datetime range. Use for a + traffic chart on the STT dashboard, or to reconcile daily usage. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CountTimeseriesResponse] + Timeseries retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/asr/usage/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CountTimeseriesResponse, + construct_type( + type_=CountTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_text_to_speech_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[TextToSpeechLogsResponse]: + """ + Paginated log of Text to Speech requests. Each entry records the model, voice, + input text length, sample rate, output format, and credits consumed. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TextToSpeechLogsResponse] + TTS logs retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/logs", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "page": page, + "pageSize": page_size, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TextToSpeechLogsResponse, + construct_type( + type_=TextToSpeechLogsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def get_text_to_speech_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[CountTimeseriesResponse]: + """ + Bucketed request count for Text to Speech over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CountTimeseriesResponse] + Timeseries retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/usage/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CountTimeseriesResponse, + construct_type( + type_=CountTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def get_text_to_speech_credits_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[CreditsTimeseriesResponse]: + """ + Bucketed credit spend for Text to Speech over a datetime range. Use this to + forecast spend or reconcile bills. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CreditsTimeseriesResponse] + Credit timeseries retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/usage/credits/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreditsTimeseriesResponse, + construct_type( + type_=CreditsTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def get_text_to_speech_concurrency_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ValuesTimeseriesResponse]: + """ + Bucketed peak concurrency (simultaneous active TTS requests) over a datetime + range. Use to size against your account concurrency cap. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ValuesTimeseriesResponse] + Concurrency timeseries retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/concurrency/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ValuesTimeseriesResponse, + construct_type( + type_=ValuesTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def get_text_to_speech_websocket_connections_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ValuesTimeseriesResponse]: + """ + Bucketed count of open TTS WebSocket connections over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ValuesTimeseriesResponse] + WebSocket-connection timeseries retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/ws-connections/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ValuesTimeseriesResponse, + construct_type( + type_=ValuesTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_webhook_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[WebhookLogsResponse]: + """ + Paginated log of webhook delivery attempts (e.g. `asr.completed` callbacks). + Each entry records the destination URL, HTTP method, event type, delivery + status, attempt number, response status code, and any error. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[WebhookLogsResponse] + Webhook logs retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/analytics/webhooks/logs", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "page": page, + "pageSize": page_size, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebhookLogsResponse, + construct_type( + type_=WebhookLogsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawAnalyticsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list_streaming_speech_to_text_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[StreamingSpeechToTextLogsResponse]: + """ + Paginated log of Speech to Text requests for the calling organization. Each entry + records the model used, the transcript, the request parameters that were on the + wire (word timestamps, diarization, PII / PCI redaction, keyword boosting), and + any downstream signals the request opted into (emotion, gender, keyword hits). + Use this to audit request-level activity or to reconcile with your own logs. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[StreamingSpeechToTextLogsResponse] + STT logs retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/asr/logs", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "page": page, + "pageSize": page_size, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + StreamingSpeechToTextLogsResponse, + construct_type( + type_=StreamingSpeechToTextLogsResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def delete_streaming_speech_to_text_history( + self, request_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[DeleteStreamingSpeechToTextHistoryResponse]: + """ + Soft-deletes a single Speech to Text request from the org's analytics history. + Idempotent: a `request_id` that does not exist still returns `success`. Use this + to redact a specific transcript from the dashboard log view. + + Parameters + ---------- + request_id : str + The `request_id` of the STT request to delete. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[DeleteStreamingSpeechToTextHistoryResponse] + History entry deleted (or was already absent). + """ + _response = await self._client_wrapper.httpx_client.request( + f"waves/v1/analytics/asr/history/{encode_path_param(request_id)}", + base_url=self._client_wrapper.get_environment().atoms, + method="DELETE", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeleteStreamingSpeechToTextHistoryResponse, + construct_type( + type_=DeleteStreamingSpeechToTextHistoryResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_streaming_speech_to_text_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[CountTimeseriesResponse]: + """ + Bucketed request count for Speech to Text over a datetime range. Use for a + traffic chart on the STT dashboard, or to reconcile daily usage. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetStreamingSpeechToTextUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CountTimeseriesResponse] + Timeseries retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/asr/usage/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CountTimeseriesResponse, + construct_type( + type_=CountTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_text_to_speech_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[TextToSpeechLogsResponse]: + """ + Paginated log of Text to Speech requests. Each entry records the model, voice, + input text length, sample rate, output format, and credits consumed. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TextToSpeechLogsResponse] + TTS logs retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/logs", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "page": page, + "pageSize": page_size, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TextToSpeechLogsResponse, + construct_type( + type_=TextToSpeechLogsResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_text_to_speech_usage_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[CountTimeseriesResponse]: + """ + Bucketed request count for Text to Speech over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechUsageTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CountTimeseriesResponse] + Timeseries retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/usage/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CountTimeseriesResponse, + construct_type( + type_=CountTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_text_to_speech_credits_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[CreditsTimeseriesResponse]: + """ + Bucketed credit spend for Text to Speech over a datetime range. Use this to + forecast spend or reconcile bills. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechCreditsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CreditsTimeseriesResponse] + Credit timeseries retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/usage/credits/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreditsTimeseriesResponse, + construct_type( + type_=CreditsTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_text_to_speech_concurrency_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ValuesTimeseriesResponse]: + """ + Bucketed peak concurrency (simultaneous active TTS requests) over a datetime + range. Use to size against your account concurrency cap. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechConcurrencyTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ValuesTimeseriesResponse] + Concurrency timeseries retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/concurrency/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ValuesTimeseriesResponse, + construct_type( + type_=ValuesTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_text_to_speech_websocket_connections_timeseries( + self, + *, + from_: typing.Optional[dt.datetime] = None, + to: typing.Optional[dt.datetime] = None, + granularity: typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ValuesTimeseriesResponse]: + """ + Bucketed count of open TTS WebSocket connections over a datetime range. + + Parameters + ---------- + from_ : typing.Optional[dt.datetime] + Start of the range, ISO 8601 datetime (e.g. `2026-08-01T00:00:00Z`). + + to : typing.Optional[dt.datetime] + End of the range, ISO 8601 datetime (e.g. `2026-08-07T00:00:00Z`). + + granularity : typing.Optional[GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity] + Bucket size for the timeseries. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ValuesTimeseriesResponse] + WebSocket-connection timeseries retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/tts/ws-connections/timeseries", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "from": serialize_datetime(from_) if from_ is not None else None, + "to": serialize_datetime(to) if to is not None else None, + "granularity": granularity, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ValuesTimeseriesResponse, + construct_type( + type_=ValuesTimeseriesResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_webhook_logs( + self, + *, + page: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[WebhookLogsResponse]: + """ + Paginated log of webhook delivery attempts (e.g. `asr.completed` callbacks). + Each entry records the destination URL, HTTP method, event type, delivery + status, attempt number, response status code, and any error. + + Parameters + ---------- + page : typing.Optional[int] + 1-indexed page number. + + page_size : typing.Optional[int] + Number of records per page. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[WebhookLogsResponse] + Webhook logs retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/analytics/webhooks/logs", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + params={ + "page": page, + "pageSize": page_size, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + WebhookLogsResponse, + construct_type( + type_=WebhookLogsResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/smallestai/waves/analytics/types/__init__.py b/src/smallestai/waves/analytics/types/__init__.py new file mode 100644 index 00000000..ed8f2a0a --- /dev/null +++ b/src/smallestai/waves/analytics/types/__init__.py @@ -0,0 +1,63 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .delete_streaming_speech_to_text_history_response import DeleteStreamingSpeechToTextHistoryResponse + from .get_streaming_speech_to_text_usage_timeseries_request_granularity import ( + GetStreamingSpeechToTextUsageTimeseriesRequestGranularity, + ) + from .get_text_to_speech_concurrency_timeseries_request_granularity import ( + GetTextToSpeechConcurrencyTimeseriesRequestGranularity, + ) + from .get_text_to_speech_credits_timeseries_request_granularity import ( + GetTextToSpeechCreditsTimeseriesRequestGranularity, + ) + from .get_text_to_speech_usage_timeseries_request_granularity import ( + GetTextToSpeechUsageTimeseriesRequestGranularity, + ) + from .get_text_to_speech_websocket_connections_timeseries_request_granularity import ( + GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity, + ) +_dynamic_imports: typing.Dict[str, str] = { + "DeleteStreamingSpeechToTextHistoryResponse": ".delete_streaming_speech_to_text_history_response", + "GetStreamingSpeechToTextUsageTimeseriesRequestGranularity": ".get_streaming_speech_to_text_usage_timeseries_request_granularity", + "GetTextToSpeechConcurrencyTimeseriesRequestGranularity": ".get_text_to_speech_concurrency_timeseries_request_granularity", + "GetTextToSpeechCreditsTimeseriesRequestGranularity": ".get_text_to_speech_credits_timeseries_request_granularity", + "GetTextToSpeechUsageTimeseriesRequestGranularity": ".get_text_to_speech_usage_timeseries_request_granularity", + "GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity": ".get_text_to_speech_websocket_connections_timeseries_request_granularity", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "DeleteStreamingSpeechToTextHistoryResponse", + "GetStreamingSpeechToTextUsageTimeseriesRequestGranularity", + "GetTextToSpeechConcurrencyTimeseriesRequestGranularity", + "GetTextToSpeechCreditsTimeseriesRequestGranularity", + "GetTextToSpeechUsageTimeseriesRequestGranularity", + "GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity", +] diff --git a/src/smallestai/waves/analytics/types/delete_streaming_speech_to_text_history_response.py b/src/smallestai/waves/analytics/types/delete_streaming_speech_to_text_history_response.py new file mode 100644 index 00000000..e87960b9 --- /dev/null +++ b/src/smallestai/waves/analytics/types/delete_streaming_speech_to_text_history_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.unchecked_base_model import UncheckedBaseModel + + +class DeleteStreamingSpeechToTextHistoryResponse(UncheckedBaseModel): + status: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/analytics/types/get_streaming_speech_to_text_usage_timeseries_request_granularity.py b/src/smallestai/waves/analytics/types/get_streaming_speech_to_text_usage_timeseries_request_granularity.py new file mode 100644 index 00000000..d2477001 --- /dev/null +++ b/src/smallestai/waves/analytics/types/get_streaming_speech_to_text_usage_timeseries_request_granularity.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +GetStreamingSpeechToTextUsageTimeseriesRequestGranularity = typing.Union[typing.Literal["hour", "day"], typing.Any] diff --git a/src/smallestai/waves/analytics/types/get_text_to_speech_concurrency_timeseries_request_granularity.py b/src/smallestai/waves/analytics/types/get_text_to_speech_concurrency_timeseries_request_granularity.py new file mode 100644 index 00000000..aa4ff450 --- /dev/null +++ b/src/smallestai/waves/analytics/types/get_text_to_speech_concurrency_timeseries_request_granularity.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +GetTextToSpeechConcurrencyTimeseriesRequestGranularity = typing.Union[typing.Literal["hour", "day"], typing.Any] diff --git a/src/smallestai/waves/analytics/types/get_text_to_speech_credits_timeseries_request_granularity.py b/src/smallestai/waves/analytics/types/get_text_to_speech_credits_timeseries_request_granularity.py new file mode 100644 index 00000000..bc8a27e4 --- /dev/null +++ b/src/smallestai/waves/analytics/types/get_text_to_speech_credits_timeseries_request_granularity.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +GetTextToSpeechCreditsTimeseriesRequestGranularity = typing.Union[typing.Literal["hour", "day"], typing.Any] diff --git a/src/smallestai/waves/analytics/types/get_text_to_speech_usage_timeseries_request_granularity.py b/src/smallestai/waves/analytics/types/get_text_to_speech_usage_timeseries_request_granularity.py new file mode 100644 index 00000000..09ad5779 --- /dev/null +++ b/src/smallestai/waves/analytics/types/get_text_to_speech_usage_timeseries_request_granularity.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +GetTextToSpeechUsageTimeseriesRequestGranularity = typing.Union[typing.Literal["hour", "day"], typing.Any] diff --git a/src/smallestai/waves/analytics/types/get_text_to_speech_websocket_connections_timeseries_request_granularity.py b/src/smallestai/waves/analytics/types/get_text_to_speech_websocket_connections_timeseries_request_granularity.py new file mode 100644 index 00000000..43c3ca1c --- /dev/null +++ b/src/smallestai/waves/analytics/types/get_text_to_speech_websocket_connections_timeseries_request_granularity.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +GetTextToSpeechWebsocketConnectionsTimeseriesRequestGranularity = typing.Union[ + typing.Literal["hour", "day"], typing.Any +] diff --git a/src/smallestai/waves/client.py b/src/smallestai/waves/client.py index cb439329..33d1b013 100644 --- a/src/smallestai/waves/client.py +++ b/src/smallestai/waves/client.py @@ -34,11 +34,15 @@ from .types.update_pronunciation_dict_response import UpdatePronunciationDictResponse if typing.TYPE_CHECKING: + from .analytics.client import AnalyticsClient, AsyncAnalyticsClient from .electron.client import AsyncElectronClient, ElectronClient + from .ops.client import AsyncOpsClient, OpsClient + from .post_call_analysis.client import AsyncPostCallAnalysisClient, PostCallAnalysisClient from .speech_to_speech.client import AsyncSpeechToSpeechClient, SpeechToSpeechClient from .speech_to_text.client import AsyncSpeechToTextClient, SpeechToTextClient from .streaming_tts.client import AsyncStreamingTtsClient, StreamingTtsClient from .tts.client import AsyncTtsClient, TtsClient + from .voices.client import AsyncVoicesClient, VoicesClient # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -49,6 +53,10 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper self._speech_to_text: typing.Optional[SpeechToTextClient] = None self._electron: typing.Optional[ElectronClient] = None + self._post_call_analysis: typing.Optional[PostCallAnalysisClient] = None + self._voices: typing.Optional[VoicesClient] = None + self._analytics: typing.Optional[AnalyticsClient] = None + self._ops: typing.Optional[OpsClient] = None self._tts: typing.Optional[TtsClient] = None self._streaming_tts: typing.Optional[StreamingTtsClient] = None self._speech_to_speech: typing.Optional[SpeechToSpeechClient] = None @@ -1015,6 +1023,38 @@ def electron(self): self._electron = ElectronClient(client_wrapper=self._client_wrapper) return self._electron + @property + def post_call_analysis(self): + if self._post_call_analysis is None: + from .post_call_analysis.client import PostCallAnalysisClient # noqa: E402 + + self._post_call_analysis = PostCallAnalysisClient(client_wrapper=self._client_wrapper) + return self._post_call_analysis + + @property + def voices(self): + if self._voices is None: + from .voices.client import VoicesClient # noqa: E402 + + self._voices = VoicesClient(client_wrapper=self._client_wrapper) + return self._voices + + @property + def analytics(self): + if self._analytics is None: + from .analytics.client import AnalyticsClient # noqa: E402 + + self._analytics = AnalyticsClient(client_wrapper=self._client_wrapper) + return self._analytics + + @property + def ops(self): + if self._ops is None: + from .ops.client import OpsClient # noqa: E402 + + self._ops = OpsClient(client_wrapper=self._client_wrapper) + return self._ops + @property def tts(self): if self._tts is None: @@ -1046,6 +1086,10 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): self._client_wrapper = client_wrapper self._speech_to_text: typing.Optional[AsyncSpeechToTextClient] = None self._electron: typing.Optional[AsyncElectronClient] = None + self._post_call_analysis: typing.Optional[AsyncPostCallAnalysisClient] = None + self._voices: typing.Optional[AsyncVoicesClient] = None + self._analytics: typing.Optional[AsyncAnalyticsClient] = None + self._ops: typing.Optional[AsyncOpsClient] = None self._tts: typing.Optional[AsyncTtsClient] = None self._streaming_tts: typing.Optional[AsyncStreamingTtsClient] = None self._speech_to_speech: typing.Optional[AsyncSpeechToSpeechClient] = None @@ -2130,6 +2174,38 @@ def electron(self): self._electron = AsyncElectronClient(client_wrapper=self._client_wrapper) return self._electron + @property + def post_call_analysis(self): + if self._post_call_analysis is None: + from .post_call_analysis.client import AsyncPostCallAnalysisClient # noqa: E402 + + self._post_call_analysis = AsyncPostCallAnalysisClient(client_wrapper=self._client_wrapper) + return self._post_call_analysis + + @property + def voices(self): + if self._voices is None: + from .voices.client import AsyncVoicesClient # noqa: E402 + + self._voices = AsyncVoicesClient(client_wrapper=self._client_wrapper) + return self._voices + + @property + def analytics(self): + if self._analytics is None: + from .analytics.client import AsyncAnalyticsClient # noqa: E402 + + self._analytics = AsyncAnalyticsClient(client_wrapper=self._client_wrapper) + return self._analytics + + @property + def ops(self): + if self._ops is None: + from .ops.client import AsyncOpsClient # noqa: E402 + + self._ops = AsyncOpsClient(client_wrapper=self._client_wrapper) + return self._ops + @property def tts(self): if self._tts is None: diff --git a/src/smallestai/waves/helpers/tts.py b/src/smallestai/waves/helpers/tts.py index 787b983e..7c0564fe 100644 --- a/src/smallestai/waves/helpers/tts.py +++ b/src/smallestai/waves/helpers/tts.py @@ -55,9 +55,7 @@ def _synthesize_stream( ) -> typing.Iterator[bytes]: if expire_content: kwargs = _kwargs_with_expiry(kwargs) - return client.waves.synthesize_tts( - text=text, voice_id=voice_id, model=model, output_format=output_format, **kwargs - ) + return client.waves.synthesize_tts(text=text, voice_id=voice_id, model=model, output_format=output_format, **kwargs) def synthesize_bytes( diff --git a/src/smallestai/waves/ops/__init__.py b/src/smallestai/waves/ops/__init__.py new file mode 100644 index 00000000..5cde0202 --- /dev/null +++ b/src/smallestai/waves/ops/__init__.py @@ -0,0 +1,4 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + diff --git a/src/smallestai/atoms/organization/client.py b/src/smallestai/waves/ops/client.py similarity index 51% rename from src/smallestai/atoms/organization/client.py rename to src/smallestai/waves/ops/client.py index 026cdc80..e4aedbdb 100644 --- a/src/smallestai/atoms/organization/client.py +++ b/src/smallestai/waves/ops/client.py @@ -4,29 +4,31 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...core.request_options import RequestOptions -from .raw_client import AsyncRawOrganizationClient, RawOrganizationClient -from .types.get_organization_response import GetOrganizationResponse +from ..types.health_response import HealthResponse +from .raw_client import AsyncRawOpsClient, RawOpsClient -class OrganizationClient: +class OpsClient: def __init__(self, *, client_wrapper: SyncClientWrapper): - self._raw_client = RawOrganizationClient(client_wrapper=client_wrapper) + self._raw_client = RawOpsClient(client_wrapper=client_wrapper) @property - def with_raw_response(self) -> RawOrganizationClient: + def with_raw_response(self) -> RawOpsClient: """ Retrieves a raw implementation of this client that returns raw responses. Returns ------- - RawOrganizationClient + RawOpsClient """ return self._raw_client - def get_organization_details( - self, *, request_options: typing.Optional[RequestOptions] = None - ) -> GetOrganizationResponse: + def get_waves_health(self, *, request_options: typing.Optional[RequestOptions] = None) -> HealthResponse: """ + Liveness probe for the Waves control plane. Returns `status: "ok"` alongside + the connection state of the backing services (MongoDB, Redis, RabbitMQ). No + authentication required. + Parameters ---------- request_options : typing.Optional[RequestOptions] @@ -34,8 +36,8 @@ def get_organization_details( Returns ------- - GetOrganizationResponse - Successful response + HealthResponse + Waves control plane is healthy. Examples -------- @@ -44,31 +46,33 @@ def get_organization_details( client = SmallestAI( api_key="YOUR_API_KEY", ) - client.atoms.organization.get_organization_details() + client.waves.ops.get_waves_health() """ - _response = self._raw_client.get_organization_details(request_options=request_options) + _response = self._raw_client.get_waves_health(request_options=request_options) return _response.data -class AsyncOrganizationClient: +class AsyncOpsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._raw_client = AsyncRawOrganizationClient(client_wrapper=client_wrapper) + self._raw_client = AsyncRawOpsClient(client_wrapper=client_wrapper) @property - def with_raw_response(self) -> AsyncRawOrganizationClient: + def with_raw_response(self) -> AsyncRawOpsClient: """ Retrieves a raw implementation of this client that returns raw responses. Returns ------- - AsyncRawOrganizationClient + AsyncRawOpsClient """ return self._raw_client - async def get_organization_details( - self, *, request_options: typing.Optional[RequestOptions] = None - ) -> GetOrganizationResponse: + async def get_waves_health(self, *, request_options: typing.Optional[RequestOptions] = None) -> HealthResponse: """ + Liveness probe for the Waves control plane. Returns `status: "ok"` alongside + the connection state of the backing services (MongoDB, Redis, RabbitMQ). No + authentication required. + Parameters ---------- request_options : typing.Optional[RequestOptions] @@ -76,8 +80,8 @@ async def get_organization_details( Returns ------- - GetOrganizationResponse - Successful response + HealthResponse + Waves control plane is healthy. Examples -------- @@ -91,10 +95,10 @@ async def get_organization_details( async def main() -> None: - await client.atoms.organization.get_organization_details() + await client.waves.ops.get_waves_health() asyncio.run(main()) """ - _response = await self._raw_client.get_organization_details(request_options=request_options) + _response = await self._raw_client.get_waves_health(request_options=request_options) return _response.data diff --git a/src/smallestai/waves/ops/raw_client.py b/src/smallestai/waves/ops/raw_client.py new file mode 100644 index 00000000..83b3171e --- /dev/null +++ b/src/smallestai/waves/ops/raw_client.py @@ -0,0 +1,132 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.parse_error import ParsingError +from ...core.request_options import RequestOptions +from ...core.unchecked_base_model import construct_type +from ..errors.service_unavailable_error import ServiceUnavailableError +from ..types.health_response import HealthResponse +from pydantic import ValidationError + + +class RawOpsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def get_waves_health( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[HealthResponse]: + """ + Liveness probe for the Waves control plane. Returns `status: "ok"` alongside + the connection state of the backing services (MongoDB, Redis, RabbitMQ). No + authentication required. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[HealthResponse] + Waves control plane is healthy. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/health", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + HealthResponse, + construct_type( + type_=HealthResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 503: + raise ServiceUnavailableError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawOpsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def get_waves_health( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[HealthResponse]: + """ + Liveness probe for the Waves control plane. Returns `status: "ok"` alongside + the connection state of the backing services (MongoDB, Redis, RabbitMQ). No + authentication required. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[HealthResponse] + Waves control plane is healthy. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/health", + base_url=self._client_wrapper.get_environment().atoms, + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + HealthResponse, + construct_type( + type_=HealthResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 503: + raise ServiceUnavailableError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/smallestai/waves/post_call_analysis/__init__.py b/src/smallestai/waves/post_call_analysis/__init__.py new file mode 100644 index 00000000..93fdde9f --- /dev/null +++ b/src/smallestai/waves/post_call_analysis/__init__.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import GeneratePostCallAnalysisResponse +_dynamic_imports: typing.Dict[str, str] = {"GeneratePostCallAnalysisResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["GeneratePostCallAnalysisResponse"] diff --git a/src/smallestai/waves/post_call_analysis/client.py b/src/smallestai/waves/post_call_analysis/client.py new file mode 100644 index 00000000..b37bab82 --- /dev/null +++ b/src/smallestai/waves/post_call_analysis/client.py @@ -0,0 +1,239 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from ..types.disposition_metric import DispositionMetric +from ..types.pca_response import PcaResponse +from .raw_client import AsyncRawPostCallAnalysisClient, RawPostCallAnalysisClient +from .types.generate_post_call_analysis_response import GeneratePostCallAnalysisResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class PostCallAnalysisClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawPostCallAnalysisClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawPostCallAnalysisClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawPostCallAnalysisClient + """ + return self._raw_client + + def analyze( + self, + *, + transcript: str, + disposition_metrics: typing.Optional[typing.Sequence[DispositionMetric]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PcaResponse: + """ + Analyze a call transcript and extract structured disposition metrics. Provide the + transcript and the metrics you want extracted; the response returns a short summary + plus one result per requested metric, each with a value, a confidence score, and the + transcript evidence for it. Values are grounded strictly in the transcript (no + inference); when evidence is absent, the type's fallback is used. + + Parameters + ---------- + transcript : str + The full call transcript to analyze. + + disposition_metrics : typing.Optional[typing.Sequence[DispositionMetric]] + The metrics to extract. Omit or pass an empty array to get just a summary. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PcaResponse + Analysis complete. + + Examples + -------- + from smallestai import SmallestAI + from smallestai.waves import DispositionMetric + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.post_call_analysis.analyze( + transcript="User: I want a refund for my torn jacket. Agent: I have logged your complaint and issued a full refund of $80.", + disposition_metrics=[ + DispositionMetric( + identifier="refund_issued", + disposition_metric_prompt="Was a refund issued to the customer?", + disposition_metric_type="BOOLEAN", + ) + ], + ) + """ + _response = self._raw_client.analyze( + transcript=transcript, disposition_metrics=disposition_metrics, request_options=request_options + ) + return _response.data + + def generate( + self, *, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> GeneratePostCallAnalysisResponse: + """ + Run a single-prompt generation and get the model's text output. A lightweight + text-in / text-out endpoint (no chat history); for multi-turn chat use the + Electron chat completions API instead. + + Parameters + ---------- + prompt : str + The prompt to generate from. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GeneratePostCallAnalysisResponse + Generation complete. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.post_call_analysis.generate( + prompt="Say hello in exactly three words.", + ) + """ + _response = self._raw_client.generate(prompt=prompt, request_options=request_options) + return _response.data + + +class AsyncPostCallAnalysisClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawPostCallAnalysisClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawPostCallAnalysisClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawPostCallAnalysisClient + """ + return self._raw_client + + async def analyze( + self, + *, + transcript: str, + disposition_metrics: typing.Optional[typing.Sequence[DispositionMetric]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PcaResponse: + """ + Analyze a call transcript and extract structured disposition metrics. Provide the + transcript and the metrics you want extracted; the response returns a short summary + plus one result per requested metric, each with a value, a confidence score, and the + transcript evidence for it. Values are grounded strictly in the transcript (no + inference); when evidence is absent, the type's fallback is used. + + Parameters + ---------- + transcript : str + The full call transcript to analyze. + + disposition_metrics : typing.Optional[typing.Sequence[DispositionMetric]] + The metrics to extract. Omit or pass an empty array to get just a summary. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PcaResponse + Analysis complete. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + from smallestai.waves import DispositionMetric + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.post_call_analysis.analyze( + transcript="User: I want a refund for my torn jacket. Agent: I have logged your complaint and issued a full refund of $80.", + disposition_metrics=[ + DispositionMetric( + identifier="refund_issued", + disposition_metric_prompt="Was a refund issued to the customer?", + disposition_metric_type="BOOLEAN", + ) + ], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.analyze( + transcript=transcript, disposition_metrics=disposition_metrics, request_options=request_options + ) + return _response.data + + async def generate( + self, *, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> GeneratePostCallAnalysisResponse: + """ + Run a single-prompt generation and get the model's text output. A lightweight + text-in / text-out endpoint (no chat history); for multi-turn chat use the + Electron chat completions API instead. + + Parameters + ---------- + prompt : str + The prompt to generate from. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GeneratePostCallAnalysisResponse + Generation complete. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.post_call_analysis.generate( + prompt="Say hello in exactly three words.", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.generate(prompt=prompt, request_options=request_options) + return _response.data diff --git a/src/smallestai/waves/post_call_analysis/raw_client.py b/src/smallestai/waves/post_call_analysis/raw_client.py new file mode 100644 index 00000000..b24af6ba --- /dev/null +++ b/src/smallestai/waves/post_call_analysis/raw_client.py @@ -0,0 +1,355 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.parse_error import ParsingError +from ...core.request_options import RequestOptions +from ...core.serialization import convert_and_respect_annotation_metadata +from ...core.unchecked_base_model import construct_type +from ..errors.bad_request_error import BadRequestError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.disposition_metric import DispositionMetric +from ..types.pca_response import PcaResponse +from .types.generate_post_call_analysis_response import GeneratePostCallAnalysisResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawPostCallAnalysisClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def analyze( + self, + *, + transcript: str, + disposition_metrics: typing.Optional[typing.Sequence[DispositionMetric]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PcaResponse]: + """ + Analyze a call transcript and extract structured disposition metrics. Provide the + transcript and the metrics you want extracted; the response returns a short summary + plus one result per requested metric, each with a value, a confidence score, and the + transcript evidence for it. Values are grounded strictly in the transcript (no + inference); when evidence is absent, the type's fallback is used. + + Parameters + ---------- + transcript : str + The full call transcript to analyze. + + disposition_metrics : typing.Optional[typing.Sequence[DispositionMetric]] + The metrics to extract. Omit or pass an empty array to get just a summary. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[PcaResponse] + Analysis complete. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/pca", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "transcript": transcript, + "dispositionMetrics": convert_and_respect_annotation_metadata( + object_=disposition_metrics, annotation=typing.Sequence[DispositionMetric], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PcaResponse, + construct_type( + type_=PcaResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def generate( + self, *, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[GeneratePostCallAnalysisResponse]: + """ + Run a single-prompt generation and get the model's text output. A lightweight + text-in / text-out endpoint (no chat history); for multi-turn chat use the + Electron chat completions API instead. + + Parameters + ---------- + prompt : str + The prompt to generate from. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[GeneratePostCallAnalysisResponse] + Generation complete. + """ + _response = self._client_wrapper.httpx_client.request( + "waves/v1/pca/generate", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "prompt": prompt, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GeneratePostCallAnalysisResponse, + construct_type( + type_=GeneratePostCallAnalysisResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawPostCallAnalysisClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def analyze( + self, + *, + transcript: str, + disposition_metrics: typing.Optional[typing.Sequence[DispositionMetric]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PcaResponse]: + """ + Analyze a call transcript and extract structured disposition metrics. Provide the + transcript and the metrics you want extracted; the response returns a short summary + plus one result per requested metric, each with a value, a confidence score, and the + transcript evidence for it. Values are grounded strictly in the transcript (no + inference); when evidence is absent, the type's fallback is used. + + Parameters + ---------- + transcript : str + The full call transcript to analyze. + + disposition_metrics : typing.Optional[typing.Sequence[DispositionMetric]] + The metrics to extract. Omit or pass an empty array to get just a summary. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[PcaResponse] + Analysis complete. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/pca", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "transcript": transcript, + "dispositionMetrics": convert_and_respect_annotation_metadata( + object_=disposition_metrics, annotation=typing.Sequence[DispositionMetric], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PcaResponse, + construct_type( + type_=PcaResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def generate( + self, *, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[GeneratePostCallAnalysisResponse]: + """ + Run a single-prompt generation and get the model's text output. A lightweight + text-in / text-out endpoint (no chat history); for multi-turn chat use the + Electron chat completions API instead. + + Parameters + ---------- + prompt : str + The prompt to generate from. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[GeneratePostCallAnalysisResponse] + Generation complete. + """ + _response = await self._client_wrapper.httpx_client.request( + "waves/v1/pca/generate", + base_url=self._client_wrapper.get_environment().atoms, + method="POST", + json={ + "prompt": prompt, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + GeneratePostCallAnalysisResponse, + construct_type( + type_=GeneratePostCallAnalysisResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + construct_type( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/smallestai/waves/post_call_analysis/types/__init__.py b/src/smallestai/waves/post_call_analysis/types/__init__.py new file mode 100644 index 00000000..13f73868 --- /dev/null +++ b/src/smallestai/waves/post_call_analysis/types/__init__.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .generate_post_call_analysis_response import GeneratePostCallAnalysisResponse +_dynamic_imports: typing.Dict[str, str] = {"GeneratePostCallAnalysisResponse": ".generate_post_call_analysis_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["GeneratePostCallAnalysisResponse"] diff --git a/src/smallestai/waves/post_call_analysis/types/generate_post_call_analysis_response.py b/src/smallestai/waves/post_call_analysis/types/generate_post_call_analysis_response.py new file mode 100644 index 00000000..140ebdda --- /dev/null +++ b/src/smallestai/waves/post_call_analysis/types/generate_post_call_analysis_response.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.unchecked_base_model import UncheckedBaseModel + + +class GeneratePostCallAnalysisResponse(UncheckedBaseModel): + output: typing.Optional[str] = pydantic.Field(default=None) + """ + The generated text. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/stream_tts.py b/src/smallestai/waves/stream_tts.py index c8262834..d7587c6d 100644 --- a/src/smallestai/waves/stream_tts.py +++ b/src/smallestai/waves/stream_tts.py @@ -22,13 +22,14 @@ This shim exists to avoid breaking customers on the 4.3.1 pattern. """ -import json + import base64 -import time -import threading +import json import queue +import threading +import time +from dataclasses import dataclass from typing import Generator, Optional, Sequence -from dataclasses import dataclass, field from websocket import WebSocketApp # from `websocket-client` package @@ -40,6 +41,7 @@ class TTSConfig: Mirrors the 4.3.1 shape; `consistency` was removed because Lightning v3.1 does not accept it (silently dropped if passed via dict). """ + voice_id: str api_key: str model: str = "lightning_v3.1" @@ -274,4 +276,3 @@ def _reset_state(self): self.is_complete = False self.is_connected = False self.request_id = None - diff --git a/src/smallestai/waves/types/__init__.py b/src/smallestai/waves/types/__init__.py index 28f21ea4..fbfd6d07 100644 --- a/src/smallestai/waves/types/__init__.py +++ b/src/smallestai/waves/types/__init__.py @@ -6,6 +6,25 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .catalog_voice import CatalogVoice + from .catalog_voice_tags import CatalogVoiceTags + from .count_timeseries_response import CountTimeseriesResponse + from .credits_timeseries_response import CreditsTimeseriesResponse + from .disposition_metric import DispositionMetric + from .disposition_metric_disposition_metric_type import DispositionMetricDispositionMetricType + from .health_response import HealthResponse + from .pca_response import PcaResponse + from .pca_response_disposition_metrics_item import PcaResponseDispositionMetricsItem + from .service_status import ServiceStatus + from .streaming_speech_to_text_log_entry import StreamingSpeechToTextLogEntry + from .streaming_speech_to_text_log_entry_request_type import StreamingSpeechToTextLogEntryRequestType + from .streaming_speech_to_text_logs_response import StreamingSpeechToTextLogsResponse + from .text_to_speech_log_entry import TextToSpeechLogEntry + from .text_to_speech_log_entry_request_type import TextToSpeechLogEntryRequestType + from .text_to_speech_logs_response import TextToSpeechLogsResponse + from .values_timeseries_response import ValuesTimeseriesResponse + from .webhook_log_entry import WebhookLogEntry + from .webhook_logs_response import WebhookLogsResponse from .async_accepted import AsyncAccepted from .audio_chunk import AudioChunk from .audio_chunk_data import AudioChunkData @@ -88,6 +107,25 @@ from .utterance import Utterance from .word import Word _dynamic_imports: typing.Dict[str, str] = { + "CatalogVoice": ".catalog_voice", + "CatalogVoiceTags": ".catalog_voice_tags", + "CountTimeseriesResponse": ".count_timeseries_response", + "CreditsTimeseriesResponse": ".credits_timeseries_response", + "DispositionMetric": ".disposition_metric", + "DispositionMetricDispositionMetricType": ".disposition_metric_disposition_metric_type", + "HealthResponse": ".health_response", + "PcaResponse": ".pca_response", + "PcaResponseDispositionMetricsItem": ".pca_response_disposition_metrics_item", + "ServiceStatus": ".service_status", + "StreamingSpeechToTextLogEntry": ".streaming_speech_to_text_log_entry", + "StreamingSpeechToTextLogEntryRequestType": ".streaming_speech_to_text_log_entry_request_type", + "StreamingSpeechToTextLogsResponse": ".streaming_speech_to_text_logs_response", + "TextToSpeechLogEntry": ".text_to_speech_log_entry", + "TextToSpeechLogEntryRequestType": ".text_to_speech_log_entry_request_type", + "TextToSpeechLogsResponse": ".text_to_speech_logs_response", + "ValuesTimeseriesResponse": ".values_timeseries_response", + "WebhookLogEntry": ".webhook_log_entry", + "WebhookLogsResponse": ".webhook_logs_response", "AsyncAccepted": ".async_accepted", "AudioChunk": ".audio_chunk", "AudioChunkData": ".audio_chunk_data", @@ -192,6 +230,25 @@ def __dir__(): __all__ = [ + "CatalogVoice", + "CatalogVoiceTags", + "CountTimeseriesResponse", + "CreditsTimeseriesResponse", + "DispositionMetric", + "DispositionMetricDispositionMetricType", + "HealthResponse", + "PcaResponse", + "PcaResponseDispositionMetricsItem", + "ServiceStatus", + "StreamingSpeechToTextLogEntry", + "StreamingSpeechToTextLogEntryRequestType", + "StreamingSpeechToTextLogsResponse", + "TextToSpeechLogEntry", + "TextToSpeechLogEntryRequestType", + "TextToSpeechLogsResponse", + "ValuesTimeseriesResponse", + "WebhookLogEntry", + "WebhookLogsResponse", "AsyncAccepted", "AudioChunk", "AudioChunkData", diff --git a/src/smallestai/waves/types/catalog_voice.py b/src/smallestai/waves/types/catalog_voice.py new file mode 100644 index 00000000..1f3bfce5 --- /dev/null +++ b/src/smallestai/waves/types/catalog_voice.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel +from .catalog_voice_tags import CatalogVoiceTags + + +class CatalogVoice(UncheckedBaseModel): + id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="_id"), + pydantic.Field(alias="_id", description="Internal voice document id."), + ] = None + voice_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="voiceId"), + pydantic.Field(alias="voiceId", description="The id to pass as `voice_id` in a TTS request."), + ] = None + display_name: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="displayName"), pydantic.Field(alias="displayName") + ] = None + description: typing.Optional[str] = None + audio_preview: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="audioPreview"), + pydantic.Field(alias="audioPreview", description="URL to a short preview clip of the voice."), + ] = None + tags: typing.Optional[CatalogVoiceTags] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/catalog_voice_tags.py b/src/smallestai/waves/types/catalog_voice_tags.py new file mode 100644 index 00000000..d9293763 --- /dev/null +++ b/src/smallestai/waves/types/catalog_voice_tags.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel + + +class CatalogVoiceTags(UncheckedBaseModel): + age: typing.Optional[str] = None + emotions: typing.Optional[typing.List[str]] = None + language: typing.Optional[typing.List[str]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/count_timeseries_response.py b/src/smallestai/waves/types/count_timeseries_response.py new file mode 100644 index 00000000..37201542 --- /dev/null +++ b/src/smallestai/waves/types/count_timeseries_response.py @@ -0,0 +1,37 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel + + +class CountTimeseriesResponse(UncheckedBaseModel): + timestamps: typing.Optional[typing.List[dt.datetime]] = pydantic.Field(default=None) + """ + Bucket boundaries as ISO 8601 datetimes. + """ + + values: typing.Optional[typing.List[int]] = pydantic.Field(default=None) + """ + Request count in each bucket, index-aligned with `timestamps`. + """ + + total_request_count: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="totalRequestCount"), + pydantic.Field(alias="totalRequestCount", description="Sum of `values` across the requested range."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/credits_timeseries_response.py b/src/smallestai/waves/types/credits_timeseries_response.py new file mode 100644 index 00000000..1e14a0d9 --- /dev/null +++ b/src/smallestai/waves/types/credits_timeseries_response.py @@ -0,0 +1,33 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel + + +class CreditsTimeseriesResponse(UncheckedBaseModel): + timestamps: typing.Optional[typing.List[dt.datetime]] = None + values: typing.Optional[typing.List[float]] = pydantic.Field(default=None) + """ + Credit spend in each bucket. + """ + + total_credits: typing_extensions.Annotated[ + typing.Optional[float], + FieldMetadata(alias="totalCredits"), + pydantic.Field(alias="totalCredits", description="Sum of credit spend across the requested range."), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/disposition_metric.py b/src/smallestai/waves/types/disposition_metric.py new file mode 100644 index 00000000..d39229c6 --- /dev/null +++ b/src/smallestai/waves/types/disposition_metric.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel +from .disposition_metric_disposition_metric_type import DispositionMetricDispositionMetricType + + +class DispositionMetric(UncheckedBaseModel): + identifier: str = pydantic.Field() + """ + A key for this metric; echoed back on the result. + """ + + disposition_metric_prompt: typing_extensions.Annotated[ + str, + FieldMetadata(alias="dispositionMetricPrompt"), + pydantic.Field( + alias="dispositionMetricPrompt", + description='What to extract, in plain language (e.g. "Was a refund issued?").', + ), + ] + disposition_metric_type: typing_extensions.Annotated[ + DispositionMetricDispositionMetricType, + FieldMetadata(alias="dispositionMetricType"), + pydantic.Field(alias="dispositionMetricType", description="The expected value type. ENUM requires `choices`."), + ] + choices: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Allowed values, required when `dispositionMetricType` is ENUM. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/disposition_metric_disposition_metric_type.py b/src/smallestai/waves/types/disposition_metric_disposition_metric_type.py new file mode 100644 index 00000000..2e787433 --- /dev/null +++ b/src/smallestai/waves/types/disposition_metric_disposition_metric_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +DispositionMetricDispositionMetricType = typing.Union[ + typing.Literal["BOOLEAN", "ENUM", "INTEGER", "STRING", "SUMMARY"], typing.Any +] diff --git a/src/smallestai/waves/types/health_response.py b/src/smallestai/waves/types/health_response.py new file mode 100644 index 00000000..ead2d81b --- /dev/null +++ b/src/smallestai/waves/types/health_response.py @@ -0,0 +1,24 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel +from .service_status import ServiceStatus + + +class HealthResponse(UncheckedBaseModel): + status: typing.Optional[str] = None + mongo: typing.Optional[ServiceStatus] = None + redis: typing.Optional[ServiceStatus] = None + rabbitmq: typing.Optional[ServiceStatus] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/pca_response.py b/src/smallestai/waves/types/pca_response.py new file mode 100644 index 00000000..15bf7fa9 --- /dev/null +++ b/src/smallestai/waves/types/pca_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel +from .pca_response_disposition_metrics_item import PcaResponseDispositionMetricsItem + + +class PcaResponse(UncheckedBaseModel): + summary: typing.Optional[str] = pydantic.Field(default=None) + """ + A short summary of the call. + """ + + disposition_metrics: typing_extensions.Annotated[ + typing.Optional[typing.List[PcaResponseDispositionMetricsItem]], + FieldMetadata(alias="dispositionMetrics"), + pydantic.Field(alias="dispositionMetrics"), + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/pca_response_disposition_metrics_item.py b/src/smallestai/waves/types/pca_response_disposition_metrics_item.py new file mode 100644 index 00000000..695785bb --- /dev/null +++ b/src/smallestai/waves/types/pca_response_disposition_metrics_item.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel + + +class PcaResponseDispositionMetricsItem(UncheckedBaseModel): + identifier: typing.Optional[str] = None + value: typing.Optional[str] = pydantic.Field(default=None) + """ + The extracted value, as a string (e.g. "true", "42", an enum choice). + """ + + confidence: typing.Optional[float] = pydantic.Field(default=None) + """ + Confidence from 0.0 to 1.0. + """ + + reasoning: typing.Optional[str] = pydantic.Field(default=None) + """ + The transcript evidence for the value, or why a fallback was used. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/service_status.py b/src/smallestai/waves/types/service_status.py new file mode 100644 index 00000000..80eeba82 --- /dev/null +++ b/src/smallestai/waves/types/service_status.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel + + +class ServiceStatus(UncheckedBaseModel): + status: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/streaming_speech_to_text_log_entry.py b/src/smallestai/waves/types/streaming_speech_to_text_log_entry.py new file mode 100644 index 00000000..936507ea --- /dev/null +++ b/src/smallestai/waves/types/streaming_speech_to_text_log_entry.py @@ -0,0 +1,106 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel +from .streaming_speech_to_text_log_entry_request_type import StreamingSpeechToTextLogEntryRequestType + + +class StreamingSpeechToTextLogEntry(UncheckedBaseModel): + request_id: typing.Optional[str] = pydantic.Field(default=None) + """ + Unique ID for the request. + """ + + timestamp: typing.Optional[str] = pydantic.Field(default=None) + """ + When the request was served (server-local time, "YYYY-MM-DD HH:MM:SS"). + """ + + request_type: typing.Optional[StreamingSpeechToTextLogEntryRequestType] = pydantic.Field(default=None) + """ + `rest` for pre-recorded transcription, `ws` for the streaming WebSocket. + """ + + model: typing.Optional[str] = pydantic.Field(default=None) + """ + STT model that served the request (e.g. `pulse`, `pulse-pro`). + """ + + language: typing.Optional[str] = pydantic.Field(default=None) + """ + Language code sent on the request (e.g. `en`, `hi`). + """ + + duration: typing.Optional[float] = pydantic.Field(default=None) + """ + Duration of the input audio, in seconds. + """ + + file_size: typing.Optional[str] = pydantic.Field(default=None) + """ + Size of the input audio, in bytes (string-encoded). + """ + + transcription: typing.Optional[str] = pydantic.Field(default=None) + """ + Final transcript. + """ + + word_timestamps: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether word-level timestamps were requested. + """ + + diarize: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether speaker diarization was requested. + """ + + redact_pii: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether PII redaction was applied. + """ + + redact_pci: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether PCI redaction was applied. + """ + + numerals: typing.Optional[str] = pydantic.Field(default=None) + """ + Numeral formatting mode (`auto`, `words`, `digits`). + """ + + keywords: typing.Optional[str] = pydantic.Field(default=None) + """ + Comma-separated list of keyword-boost terms on the request. + """ + + emotion_detection: typing.Optional[bool] = None + gender_detection: typing.Optional[bool] = None + emotions: typing.Optional[str] = pydantic.Field(default=None) + """ + Detected emotions, if `emotion_detection` was enabled. + """ + + gender: typing.Optional[str] = pydantic.Field(default=None) + """ + Detected gender, if `gender_detection` was enabled. + """ + + webhook_url: typing.Optional[str] = pydantic.Field(default=None) + """ + Webhook URL the completion was posted to, if async. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/streaming_speech_to_text_log_entry_request_type.py b/src/smallestai/waves/types/streaming_speech_to_text_log_entry_request_type.py new file mode 100644 index 00000000..e35827f6 --- /dev/null +++ b/src/smallestai/waves/types/streaming_speech_to_text_log_entry_request_type.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +StreamingSpeechToTextLogEntryRequestType = typing.Union[typing.Literal["rest", "ws"], typing.Any] diff --git a/src/smallestai/waves/types/streaming_speech_to_text_logs_response.py b/src/smallestai/waves/types/streaming_speech_to_text_logs_response.py new file mode 100644 index 00000000..fbd61153 --- /dev/null +++ b/src/smallestai/waves/types/streaming_speech_to_text_logs_response.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel +from .streaming_speech_to_text_log_entry import StreamingSpeechToTextLogEntry + + +class StreamingSpeechToTextLogsResponse(UncheckedBaseModel): + status: typing.Optional[str] = None + data: typing.Optional[typing.List[StreamingSpeechToTextLogEntry]] = None + page: typing.Optional[int] = None + page_size: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="pageSize"), pydantic.Field(alias="pageSize") + ] = None + total_count: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalCount"), pydantic.Field(alias="totalCount") + ] = None + total_pages: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalPages"), pydantic.Field(alias="totalPages") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/text_to_speech_log_entry.py b/src/smallestai/waves/types/text_to_speech_log_entry.py new file mode 100644 index 00000000..89b80bd1 --- /dev/null +++ b/src/smallestai/waves/types/text_to_speech_log_entry.py @@ -0,0 +1,66 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel +from .text_to_speech_log_entry_request_type import TextToSpeechLogEntryRequestType + + +class TextToSpeechLogEntry(UncheckedBaseModel): + timestamp: typing.Optional[str] = pydantic.Field(default=None) + """ + When the request was served. + """ + + request_type: typing.Optional[TextToSpeechLogEntryRequestType] = pydantic.Field(default=None) + """ + `rest` for the pre-recorded route, `ws` for the streaming WebSocket. + """ + + model: typing.Optional[str] = pydantic.Field(default=None) + """ + TTS model that served the request (e.g. `lightning-v3.1`, `lightning-v3.1-pro`). + """ + + voice_id: typing.Optional[str] = None + language: typing.Optional[str] = None + text: typing.Optional[str] = pydantic.Field(default=None) + """ + Input text (truncated for very long inputs). + """ + + text_len: typing.Optional[int] = pydantic.Field(default=None) + """ + Length of the input text in characters. + """ + + credits: typing.Optional[float] = pydantic.Field(default=None) + """ + Credits consumed by the request. + """ + + speed: typing.Optional[float] = None + sample_rate: typing.Optional[int] = None + output_format: typing.Optional[str] = pydantic.Field(default=None) + """ + Audio container/encoding requested (`pcm`, `wav`, `mp3`, `ulaw`, `alaw`). + """ + + enhancement: typing.Optional[float] = None + similarity: typing.Optional[float] = None + consistency: typing.Optional[float] = None + is_pvc: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether the voice used was a Personal Voice Clone. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/text_to_speech_log_entry_request_type.py b/src/smallestai/waves/types/text_to_speech_log_entry_request_type.py new file mode 100644 index 00000000..599a30c7 --- /dev/null +++ b/src/smallestai/waves/types/text_to_speech_log_entry_request_type.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +TextToSpeechLogEntryRequestType = typing.Union[typing.Literal["rest", "ws"], typing.Any] diff --git a/src/smallestai/waves/types/text_to_speech_logs_response.py b/src/smallestai/waves/types/text_to_speech_logs_response.py new file mode 100644 index 00000000..4cfcf92f --- /dev/null +++ b/src/smallestai/waves/types/text_to_speech_logs_response.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel +from .text_to_speech_log_entry import TextToSpeechLogEntry + + +class TextToSpeechLogsResponse(UncheckedBaseModel): + status: typing.Optional[str] = None + data: typing.Optional[typing.List[TextToSpeechLogEntry]] = None + page: typing.Optional[int] = None + page_size: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="pageSize"), pydantic.Field(alias="pageSize") + ] = None + total_count: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalCount"), pydantic.Field(alias="totalCount") + ] = None + total_pages: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalPages"), pydantic.Field(alias="totalPages") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/values_timeseries_response.py b/src/smallestai/waves/types/values_timeseries_response.py new file mode 100644 index 00000000..ecf4aff0 --- /dev/null +++ b/src/smallestai/waves/types/values_timeseries_response.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel + + +class ValuesTimeseriesResponse(UncheckedBaseModel): + timestamps: typing.Optional[typing.List[dt.datetime]] = None + values: typing.Optional[typing.List[float]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/webhook_log_entry.py b/src/smallestai/waves/types/webhook_log_entry.py new file mode 100644 index 00000000..1321eb7f --- /dev/null +++ b/src/smallestai/waves/types/webhook_log_entry.py @@ -0,0 +1,65 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.unchecked_base_model import UncheckedBaseModel + + +class WebhookLogEntry(UncheckedBaseModel): + org_id: typing.Optional[str] = pydantic.Field(default=None) + """ + The organization that owns the webhook. + """ + + request_id: typing.Optional[str] = pydantic.Field(default=None) + """ + Correlates to the source request (e.g. the STT `request_id`). + """ + + webhook_url: typing.Optional[str] = None + method: typing.Optional[str] = pydantic.Field(default=None) + """ + HTTP method used to deliver the event. + """ + + event_type: typing.Optional[str] = pydantic.Field(default=None) + """ + Event that fired (e.g. `asr.completed`). + """ + + status: typing.Optional[str] = pydantic.Field(default=None) + """ + Delivery outcome (`success`, `failed`, `pending`). + """ + + attempt: typing.Optional[int] = pydantic.Field(default=None) + """ + Attempt count, 1-indexed. Failures are retried with backoff. + """ + + response_status_code: typing.Optional[int] = pydantic.Field(default=None) + """ + HTTP status returned by the destination. + """ + + error: typing.Optional[str] = pydantic.Field(default=None) + """ + Failure reason, if any. + """ + + timestamp: typing.Optional[str] = None + duration_ms: typing.Optional[int] = pydantic.Field(default=None) + """ + Round-trip time of the delivery attempt, in milliseconds. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/types/webhook_logs_response.py b/src/smallestai/waves/types/webhook_logs_response.py new file mode 100644 index 00000000..95d3e631 --- /dev/null +++ b/src/smallestai/waves/types/webhook_logs_response.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2 +from ...core.serialization import FieldMetadata +from ...core.unchecked_base_model import UncheckedBaseModel +from .webhook_log_entry import WebhookLogEntry + + +class WebhookLogsResponse(UncheckedBaseModel): + status: typing.Optional[str] = None + data: typing.Optional[typing.List[WebhookLogEntry]] = None + page: typing.Optional[int] = None + page_size: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="pageSize"), pydantic.Field(alias="pageSize") + ] = None + total_count: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalCount"), pydantic.Field(alias="totalCount") + ] = None + total_pages: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="totalPages"), pydantic.Field(alias="totalPages") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/smallestai/waves/voices/__init__.py b/src/smallestai/waves/voices/__init__.py new file mode 100644 index 00000000..14ba714f --- /dev/null +++ b/src/smallestai/waves/voices/__init__.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import GetAllVoiceModelsResponse +_dynamic_imports: typing.Dict[str, str] = {"GetAllVoiceModelsResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["GetAllVoiceModelsResponse"] diff --git a/src/smallestai/waves/voices/client.py b/src/smallestai/waves/voices/client.py new file mode 100644 index 00000000..9b45364f --- /dev/null +++ b/src/smallestai/waves/voices/client.py @@ -0,0 +1,110 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from .raw_client import AsyncRawVoicesClient, RawVoicesClient +from .types.get_all_voice_models_response import GetAllVoiceModelsResponse + + +class VoicesClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawVoicesClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawVoicesClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawVoicesClient + """ + return self._raw_client + + def get_all_voice_models( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> GetAllVoiceModelsResponse: + """ + List every available voice with its display name, description, a preview audio URL, + and metadata tags (age, supported languages). Use a voice's `voiceId` with the + unified `/waves/v1/tts` route. This is the browse/catalog endpoint; to list only the + voices for a specific model, use `GET /waves/v1/{model}/get_voices`. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetAllVoiceModelsResponse + Voices retrieved. + + Examples + -------- + from smallestai import SmallestAI + + client = SmallestAI( + api_key="YOUR_API_KEY", + ) + client.waves.voices.get_all_voice_models() + """ + _response = self._raw_client.get_all_voice_models(request_options=request_options) + return _response.data + + +class AsyncVoicesClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawVoicesClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawVoicesClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawVoicesClient + """ + return self._raw_client + + async def get_all_voice_models( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> GetAllVoiceModelsResponse: + """ + List every available voice with its display name, description, a preview audio URL, + and metadata tags (age, supported languages). Use a voice's `voiceId` with the + unified `/waves/v1/tts` route. This is the browse/catalog endpoint; to list only the + voices for a specific model, use `GET /waves/v1/{model}/get_voices`. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + GetAllVoiceModelsResponse + Voices retrieved. + + Examples + -------- + import asyncio + + from smallestai import AsyncSmallestAI + + client = AsyncSmallestAI( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.waves.voices.get_all_voice_models() + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_all_voice_models(request_options=request_options) + return _response.data diff --git a/src/smallestai/atoms/organization/raw_client.py b/src/smallestai/waves/voices/raw_client.py similarity index 71% rename from src/smallestai/atoms/organization/raw_client.py rename to src/smallestai/waves/voices/raw_client.py index 90a30d7c..86232e89 100644 --- a/src/smallestai/atoms/organization/raw_client.py +++ b/src/smallestai/waves/voices/raw_client.py @@ -9,20 +9,24 @@ from ...core.parse_error import ParsingError from ...core.request_options import RequestOptions from ...core.unchecked_base_model import construct_type -from ..errors.internal_server_error import InternalServerError from ..errors.unauthorized_error import UnauthorizedError -from .types.get_organization_response import GetOrganizationResponse +from .types.get_all_voice_models_response import GetAllVoiceModelsResponse from pydantic import ValidationError -class RawOrganizationClient: +class RawVoicesClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper - def get_organization_details( + def get_all_voice_models( self, *, request_options: typing.Optional[RequestOptions] = None - ) -> HttpResponse[GetOrganizationResponse]: + ) -> HttpResponse[GetAllVoiceModelsResponse]: """ + List every available voice with its display name, description, a preview audio URL, + and metadata tags (age, supported languages). Use a voice's `voiceId` with the + unified `/waves/v1/tts` route. This is the browse/catalog endpoint; to list only the + voices for a specific model, use `GET /waves/v1/{model}/get_voices`. + Parameters ---------- request_options : typing.Optional[RequestOptions] @@ -30,11 +34,11 @@ def get_organization_details( Returns ------- - HttpResponse[GetOrganizationResponse] - Successful response + HttpResponse[GetAllVoiceModelsResponse] + Voices retrieved. """ _response = self._client_wrapper.httpx_client.request( - "organization", + "waves/v1/voice/get-all-models", base_url=self._client_wrapper.get_environment().atoms, method="GET", request_options=request_options, @@ -42,9 +46,9 @@ def get_organization_details( try: if 200 <= _response.status_code < 300: _data = typing.cast( - GetOrganizationResponse, + GetAllVoiceModelsResponse, construct_type( - type_=GetOrganizationResponse, # type: ignore + type_=GetAllVoiceModelsResponse, # type: ignore object_=_response.json(), ), ) @@ -60,17 +64,6 @@ def get_organization_details( ), ), ) - if _response.status_code == 500: - raise InternalServerError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - construct_type( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) _response_json = _response.json() except JSONDecodeError: raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) @@ -81,14 +74,19 @@ def get_organization_details( raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) -class AsyncRawOrganizationClient: +class AsyncRawVoicesClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): self._client_wrapper = client_wrapper - async def get_organization_details( + async def get_all_voice_models( self, *, request_options: typing.Optional[RequestOptions] = None - ) -> AsyncHttpResponse[GetOrganizationResponse]: + ) -> AsyncHttpResponse[GetAllVoiceModelsResponse]: """ + List every available voice with its display name, description, a preview audio URL, + and metadata tags (age, supported languages). Use a voice's `voiceId` with the + unified `/waves/v1/tts` route. This is the browse/catalog endpoint; to list only the + voices for a specific model, use `GET /waves/v1/{model}/get_voices`. + Parameters ---------- request_options : typing.Optional[RequestOptions] @@ -96,11 +94,11 @@ async def get_organization_details( Returns ------- - AsyncHttpResponse[GetOrganizationResponse] - Successful response + AsyncHttpResponse[GetAllVoiceModelsResponse] + Voices retrieved. """ _response = await self._client_wrapper.httpx_client.request( - "organization", + "waves/v1/voice/get-all-models", base_url=self._client_wrapper.get_environment().atoms, method="GET", request_options=request_options, @@ -108,9 +106,9 @@ async def get_organization_details( try: if 200 <= _response.status_code < 300: _data = typing.cast( - GetOrganizationResponse, + GetAllVoiceModelsResponse, construct_type( - type_=GetOrganizationResponse, # type: ignore + type_=GetAllVoiceModelsResponse, # type: ignore object_=_response.json(), ), ) @@ -126,17 +124,6 @@ async def get_organization_details( ), ), ) - if _response.status_code == 500: - raise InternalServerError( - headers=dict(_response.headers), - body=typing.cast( - typing.Any, - construct_type( - type_=typing.Any, # type: ignore - object_=_response.json(), - ), - ), - ) _response_json = _response.json() except JSONDecodeError: raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) diff --git a/src/smallestai/waves/voices/types/__init__.py b/src/smallestai/waves/voices/types/__init__.py new file mode 100644 index 00000000..292993b4 --- /dev/null +++ b/src/smallestai/waves/voices/types/__init__.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .get_all_voice_models_response import GetAllVoiceModelsResponse +_dynamic_imports: typing.Dict[str, str] = {"GetAllVoiceModelsResponse": ".get_all_voice_models_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["GetAllVoiceModelsResponse"] diff --git a/src/smallestai/waves/voices/types/get_all_voice_models_response.py b/src/smallestai/waves/voices/types/get_all_voice_models_response.py new file mode 100644 index 00000000..ae56a452 --- /dev/null +++ b/src/smallestai/waves/voices/types/get_all_voice_models_response.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.unchecked_base_model import UncheckedBaseModel +from ...types.catalog_voice import CatalogVoice + + +class GetAllVoiceModelsResponse(UncheckedBaseModel): + voices: typing.Optional[typing.List[CatalogVoice]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/tests/custom/test_agent_tools_helper.py b/tests/custom/test_agent_tools_helper.py index f7cb42e8..2e960d1c 100644 --- a/tests/custom/test_agent_tools_helper.py +++ b/tests/custom/test_agent_tools_helper.py @@ -56,23 +56,40 @@ class FakeBranchTransport: def __init__(self, tools): self.resolved_tools = list(tools) # current live tools - self.puts = [] # (url, body) + self.puts = [] # (url, body) self.published = 0 self.made_live = 0 # GET /agent/{id}/branches and /branches/{bid}/revisions/{head} def get(self, url, headers=None, timeout=None): if url.endswith("/branches"): - return FakeResponse({"data": {"branches": [{ - "isLive": True, - "branch": {"_id": BRANCH_ID, "name": "main", "isDefault": True, - "status": "active", "headRevisionId": HEAD}, - }]}}) + return FakeResponse( + { + "data": { + "branches": [ + { + "isLive": True, + "branch": { + "_id": BRANCH_ID, + "name": "main", + "isDefault": True, + "status": "active", + "headRevisionId": HEAD, + }, + } + ] + } + } + ) if "/revisions/" in url: - return FakeResponse({"data": { - "revision": {"status": "published"}, - "resolvedConfig": {"workflow_tools": {"tools": self.resolved_tools}}, - }}) + return FakeResponse( + { + "data": { + "revision": {"status": "published"}, + "resolvedConfig": {"workflow_tools": {"tools": self.resolved_tools}}, + } + } + ) return FakeResponse({"data": {}}) def put(self, url, headers=None, json=None, timeout=None): @@ -146,10 +163,12 @@ def test_replace_overwrites_all(wired): def test_remove_tool(wired): - helper, t = wired([ - {"type": "transfer_call", "name": "transfer_call"}, - {"type": "end_call", "name": "end_call"}, - ]) + helper, t = wired( + [ + {"type": "transfer_call", "name": "transfer_call"}, + {"type": "end_call", "name": "end_call"}, + ] + ) helper.remove_tool("AG1", "transfer_call") _, body = t.puts[0] assert [x["name"] for x in body["singlePromptConfig"]["tools"]] == ["end_call"] @@ -190,7 +209,15 @@ def empty_get(url, headers=None, timeout=None): def test_api_error_body_is_surfaced(monkeypatch): def get(url, headers=None, timeout=None): - return FakeResponse({"data": {"branches": [{"isLive": True, "branch": {"_id": BRANCH_ID, "headRevisionId": HEAD, "isDefault": True}}]}}) + return FakeResponse( + { + "data": { + "branches": [ + {"isLive": True, "branch": {"_id": BRANCH_ID, "headRevisionId": HEAD, "isDefault": True}} + ] + } + } + ) def bad_put(url, headers=None, json=None, timeout=None): return FakeResponse({"status": False, "errors": ["invalid tool"]}, status_code=400) diff --git a/tests/custom/test_as_page_envelope.py b/tests/custom/test_as_page_envelope.py index 02515fac..a86f5788 100644 --- a/tests/custom/test_as_page_envelope.py +++ b/tests/custom/test_as_page_envelope.py @@ -4,6 +4,7 @@ silently return the whole envelope as a single bogus item instead of the rows (found in pre-merge review: `retries`, and the dual telephony/custom arrays). """ + from smallestai.atoms.helpers import as_page diff --git a/tests/custom/test_cli_banner_mcp.py b/tests/custom/test_cli_banner_mcp.py new file mode 100644 index 00000000..99c26f04 --- /dev/null +++ b/tests/custom/test_cli_banner_mcp.py @@ -0,0 +1,34 @@ +"""Bare `smallestai` shows a banner (not an error), and `mcp` prints setup config.""" + +from typer.testing import CliRunner + +from smallestai.cli.main import app + +runner = CliRunner() + + +def test_bare_cli_shows_banner_and_commands_not_error(): + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert "Missing command" not in result.output + # command list is present + for name in ("agent-crew", "agents", "calls", "waves", "mcp"): + assert name in result.output + + +def test_mcp_prints_setup_config(): + result = runner.invoke(app, ["mcp"]) + assert result.exit_code == 0 + assert "@developer-smallestai/smallest-mcp-server" in result.output + + +def test_mcp_config_subcommand_emits_json(): + result = runner.invoke(app, ["mcp", "config"]) + assert result.exit_code == 0 + assert "mcpServers" in result.output + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"]) diff --git a/tests/custom/test_cli_calls.py b/tests/custom/test_cli_calls.py index f219362c..ea7b5372 100644 --- a/tests/custom/test_cli_calls.py +++ b/tests/custom/test_cli_calls.py @@ -54,10 +54,20 @@ def test_fmt_dur(): def test_list_renders_and_passes_filters(app_with): - calls = FakeCalls(logs=[ - _obj(call_id="CALL-1", type="telephony_outbound", status="completed", duration=89, - from_="+111", to="+222", created_at="2026-08-04T09:06:07.000Z", recording_url="u"), - ]) + calls = FakeCalls( + logs=[ + _obj( + call_id="CALL-1", + type="telephony_outbound", + status="completed", + duration=89, + from_="+111", + to="+222", + created_at="2026-08-04T09:06:07.000Z", + recording_url="u", + ), + ] + ) app = app_with(calls) res = runner.invoke(app, ["list", "--agent-id", "AG1", "--type", "telephony_outbound", "--limit", "5"]) assert res.exit_code == 0, res.output @@ -68,9 +78,17 @@ def test_list_renders_and_passes_filters(app_with): def test_get_shows_fields_and_recording(app_with): - call = _obj(status="completed", type="telephony_outbound", duration=89, from_="+1", to="+2", - transcript=[_obj(role="user", content="hi")], call_cost=0.17, - call_failure_reason=None, recording_url="https://rec/x.wav") + call = _obj( + status="completed", + type="telephony_outbound", + duration=89, + from_="+1", + to="+2", + transcript=[_obj(role="user", content="hi")], + call_cost=0.17, + call_failure_reason=None, + recording_url="https://rec/x.wav", + ) app = app_with(FakeCalls(call=call)) res = runner.invoke(app, ["get", "CALL-9"]) assert res.exit_code == 0, res.output diff --git a/tests/custom/test_crew_context_from_llm_request.py b/tests/custom/test_crew_context_from_llm_request.py index 91ad6381..eff166d8 100644 --- a/tests/custom/test_crew_context_from_llm_request.py +++ b/tests/custom/test_crew_context_from_llm_request.py @@ -6,11 +6,12 @@ silent for the whole call. The LLM-request event carries the platform's authoritative message list, so we seed the context from it before generating. """ + import unittest from unittest import mock +from smallestai.atoms.crew.events import SDKAgentTranscriptUpdateEvent, SDKSystemLLMRequestEvent from smallestai.atoms.crew.nodes import OutputCrewNode -from smallestai.atoms.crew.events import SDKSystemLLMRequestEvent, SDKAgentTranscriptUpdateEvent class _Agent(OutputCrewNode): @@ -45,9 +46,7 @@ async def fake_handle(): seen["had_user"] = any(m.get("role") == "user" for m in agent.context.messages) agent._handle_llm_request = fake_handle - await agent.process_event( - SDKSystemLLMRequestEvent(messages=[{"role": "user", "content": "hi"}]) - ) + await agent.process_event(SDKSystemLLMRequestEvent(messages=[{"role": "user", "content": "hi"}])) assert seen["had_user"] is True async def test_no_messages_falls_back_to_accumulated_context(self): @@ -63,9 +62,7 @@ async def test_node_system_prompt_is_preserved(self): agent = _Agent() agent.send_event = mock.AsyncMock() agent.context.add_message({"role": "system", "content": "CREW SYSTEM PROMPT"}) - await agent.process_event( - SDKSystemLLMRequestEvent(messages=[{"role": "user", "content": "hi"}]) - ) + await agent.process_event(SDKSystemLLMRequestEvent(messages=[{"role": "user", "content": "hi"}])) assert agent.context.messages == [ {"role": "system", "content": "CREW SYSTEM PROMPT"}, {"role": "user", "content": "hi"}, @@ -79,7 +76,6 @@ async def test_event_messages_are_authoritative_over_stale_accumulation(self): await agent.process_event(SDKSystemLLMRequestEvent(messages=fresh)) assert agent.context.messages == fresh - async def test_empty_list_messages_keeps_accumulated_context(self): """A request whose messages is [] must not wipe context to a system-only list.""" agent = _Agent() diff --git a/tests/custom/test_crew_ownership_cli.py b/tests/custom/test_crew_ownership_cli.py new file mode 100644 index 00000000..1322ba8a --- /dev/null +++ b/tests/custom/test_crew_ownership_cli.py @@ -0,0 +1,43 @@ +"""Crew config-ownership surfacing: ownership module, deprecation, doctor command.""" + +import warnings + +from rich.console import Console + + +def test_ownership_module_has_data_and_renders(): + from smallestai.cli.lib import ownership + + assert ownership.CREW_OWNS + assert ownership.PLATFORM_OWNS + assert ownership.DASHBOARD_NO_OPS + assert "LLM turn" in ownership.SUMMARY + # any platform-owned field mentions redaction (the key gotcha) + assert any("redaction" in field.lower() for _, field in ownership.PLATFORM_OWNS) + ownership.render_ownership(Console()) # must not raise + + +def test_override_event_is_deprecated(): + from smallestai.atoms.crew.events import SDKSystemUpdateOutputAgentSettingsEvent + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + SDKSystemUpdateOutputAgentSettingsEvent(settings={}) + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + +def test_agent_crew_app_exposes_doctor(): + from smallestai.cli.agent_crew import initialise_agent_crew_app + from smallestai.cli.lib.atoms import AtomsAPIClient + from smallestai.cli.lib.auth import AuthClient + from smallestai.cli.lib.project_config import ProjectConfig + + app = initialise_agent_crew_app(ProjectConfig(), AuthClient(), AtomsAPIClient()) + names = {cmd.name or cmd.callback.__name__ for cmd in app.registered_commands} + assert "doctor" in names + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"]) diff --git a/tests/custom/test_crew_session_ready_ordering.py b/tests/custom/test_crew_session_ready_ordering.py index bcb5c25e..7587b198 100644 --- a/tests/custom/test_crew_session_ready_ordering.py +++ b/tests/custom/test_crew_session_ready_ordering.py @@ -1,11 +1,12 @@ """Regression test: CrewSession.start() must send SDKAgentReadyEvent BEFORE starting nodes, so a node's start() that emits a speak (e.g. a greeting) doesn't reach the platform before Ready and poison the connect handshake.""" + import unittest from unittest import mock -from smallestai.atoms.crew.session import CrewSession from smallestai.atoms.crew.nodes import OutputCrewNode +from smallestai.atoms.crew.session import CrewSession class _OrderNode(OutputCrewNode): @@ -25,9 +26,7 @@ async def generate_response(self): class ReadyBeforeNodesTest(unittest.IsolatedAsyncioTestCase): async def test_ready_sent_before_nodes_start(self): order = [] - session = CrewSession( - websocket=mock.AsyncMock(), session_id="t", setup_handler=None - ) + session = CrewSession(websocket=mock.AsyncMock(), session_id="t", setup_handler=None) session._init_event = mock.MagicMock() session.task_manager = mock.MagicMock() session.task_manager.create_task = mock.MagicMock() # don't run receive loop diff --git a/tests/custom/test_crew_terminal_handoff_guard.py b/tests/custom/test_crew_terminal_handoff_guard.py index d871e7ed..48a9d75f 100644 --- a/tests/custom/test_crew_terminal_handoff_guard.py +++ b/tests/custom/test_crew_terminal_handoff_guard.py @@ -6,18 +6,19 @@ (observed ~40x "please hold" during a ~30s transfer dial). Once a handoff event is emitted, the node latches and ignores further LLM requests. """ + import unittest from unittest import mock -from smallestai.atoms.crew.nodes import OutputCrewNode -from smallestai.atoms.crew.nodes.base import CrewNode from smallestai.atoms.crew.events import ( - SDKSystemLLMRequestEvent, - SDKAgentTransferConversationEvent, SDKAgentEndCallEvent, + SDKAgentTransferConversationEvent, + SDKSystemLLMRequestEvent, TransferOption, TransferOptionType, ) +from smallestai.atoms.crew.nodes import OutputCrewNode +from smallestai.atoms.crew.nodes.base import CrewNode class _Agent(OutputCrewNode): @@ -67,6 +68,7 @@ async def test_non_terminal_event_does_not_latch(self): a = _Agent() a._handle_llm_request = mock.AsyncMock() from smallestai.atoms.crew.events import SDKAgentSpeakEvent + with mock.patch.object(CrewNode, "send_event", new=mock.AsyncMock()): await a.send_event(SDKAgentSpeakEvent(text="hello")) self.assertFalse(a._handoff_started) diff --git a/tests/custom/test_error_devx.py b/tests/custom/test_error_devx.py index 3a2c932b..3bcbfa21 100644 --- a/tests/custom/test_error_devx.py +++ b/tests/custom/test_error_devx.py @@ -5,6 +5,7 @@ main-backend access.middleware.ts). It should surface as PlanNotEntitledError while staying a BadRequestError (backward-compatible). """ + import unittest from smallestai import PlanNotEntitledError as PlanNotEntitledFromRoot @@ -19,7 +20,9 @@ } _LIMIT_BODY = { "status": False, - "errors": ["You have reached the maximum number of agents for your plan, please upgrade to a higher plan to create more agents"], + "errors": [ + "You have reached the maximum number of agents for your plan, please upgrade to a higher plan to create more agents" + ], } diff --git a/tests/custom/test_helpers_rest.py b/tests/custom/test_helpers_rest.py index 5e709b39..51d9bf3a 100644 --- a/tests/custom/test_helpers_rest.py +++ b/tests/custom/test_helpers_rest.py @@ -4,6 +4,7 @@ builds the right URL with bearer auth. Guards the helpers the verify harness flagged as untested. """ + import smallestai.atoms.helpers.audience as audience_mod import smallestai.atoms.helpers.campaign as campaign_mod import smallestai.atoms.helpers.kb as kb_mod @@ -32,6 +33,7 @@ def _record(self, method): def fn(url, headers=None, **kw): self.last = (method, url, headers or {}) return self._resp + return fn def __getattr__(self, name): diff --git a/tests/custom/test_output_node_on_event_hook.py b/tests/custom/test_output_node_on_event_hook.py index fa077ce3..69636dd5 100644 --- a/tests/custom/test_output_node_on_event_hook.py +++ b/tests/custom/test_output_node_on_event_hook.py @@ -2,15 +2,17 @@ user hook without super(). Overriding `on_event` (the documented extension point) must NOT silence the LLM-request -> generate_response path, and existing `process_event`-with-super() overrides must keep working (backward compat).""" + import unittest from unittest import mock -from smallestai.atoms.crew.nodes import OutputCrewNode from smallestai.atoms.crew.events import SDKSystemLLMRequestEvent +from smallestai.atoms.crew.nodes import OutputCrewNode class _OnEventAgent(OutputCrewNode): """Recommended pattern: override on_event, no super().""" + def __init__(self, order): super().__init__(name="a") self._order = order @@ -25,6 +27,7 @@ async def generate_response(self): class _LegacyProcessEventAgent(OutputCrewNode): """Legacy pattern: override process_event and call super().""" + def __init__(self, order): super().__init__(name="b") self._order = order diff --git a/tests/custom/test_sdk_fixes.py b/tests/custom/test_sdk_fixes.py index 2e5f1be8..0ea5f6cd 100644 --- a/tests/custom/test_sdk_fixes.py +++ b/tests/custom/test_sdk_fixes.py @@ -1,6 +1,6 @@ """Unit tests for the hand-written SDK fixes (no network). Run by CI's test job.""" + import os -import warnings from types import SimpleNamespace as NS import pytest @@ -9,23 +9,27 @@ def test_openai_client_raises_without_key(): os.environ.pop("OPENAI_API_KEY", None) from smallestai.atoms.crew.clients.openai import OpenAIClient + with pytest.raises(ValueError): OpenAIClient(api_key=None) # #1: must raise, not silently warn def test_openai_client_ok_with_key(): from smallestai.atoms.crew.clients.openai import OpenAIClient + assert OpenAIClient(api_key="sk_test") is not None def test_openai_electron_factory(): from smallestai.atoms.crew.clients.openai import OpenAIClient + os.environ["SMALLEST_API_KEY"] = "sk_dummy" assert OpenAIClient.electron() is not None # #12 def test_call_analytics_rename_and_alias(): - from smallestai.atoms.helpers import CallAnalytics, Call + from smallestai.atoms.helpers import Call, CallAnalytics + assert CallAnalytics(api_key="x") is not None assert issubclass(Call, CallAnalytics) with pytest.warns(DeprecationWarning): # #15 @@ -38,6 +42,7 @@ def test_helpers_import_clean(): def test_as_page_normalizes_shapes(): from smallestai.atoms.helpers import as_page # #25 + assert as_page(NS(data=NS(agents=[1, 2], total_count=2, has_more=False))).items == [1, 2] p = as_page(NS(data=NS(logs=[1], pagination=NS(total=1, has_more=True)))) assert p.items == [1] and p.total_count == 1 and p.has_more is True @@ -47,6 +52,7 @@ def test_as_page_normalizes_shapes(): def test_require_id_guard(): from smallestai.atoms.helpers import require_id # #18 + for bad in ["", " ", None]: with pytest.raises(ValueError): require_id(bad) diff --git a/tests/custom/test_source_headers.py b/tests/custom/test_source_headers.py index 8613e6c7..e15aa8fe 100644 --- a/tests/custom/test_source_headers.py +++ b/tests/custom/test_source_headers.py @@ -5,6 +5,7 @@ the real installed version in X-Fern-SDK-Version instead of the generator's hardcoded 0.0.0 so version adoption is trackable. See client_wrapper.py. """ + import re from smallestai import __version__ @@ -13,9 +14,7 @@ def _headers(): - return BaseClientWrapper( - api_key="test-key", environment=SmallestAIEnvironment.PRODUCTION - ).get_headers() + return BaseClientWrapper(api_key="test-key", environment=SmallestAIEnvironment.PRODUCTION).get_headers() def test_x_source_present(): diff --git a/tests/custom/test_streaming_stt_helper.py b/tests/custom/test_streaming_stt_helper.py index bb85ab7f..07ea6294 100644 --- a/tests/custom/test_streaming_stt_helper.py +++ b/tests/custom/test_streaming_stt_helper.py @@ -100,9 +100,7 @@ def test_none_optionals_are_omitted(): def test_numeric_optionals_pass_through(): - q = build_stt_stream_query( - language="en", eou_timeout_ms=1000, vad_threshold=0.5, vad_min_speech_ms=120 - ) + q = build_stt_stream_query(language="en", eou_timeout_ms=1000, vad_threshold=0.5, vad_min_speech_ms=120) assert q["eou_timeout_ms"] == 1000 assert q["vad_threshold"] == 0.5 assert q["vad_min_speech_ms"] == 120 @@ -135,9 +133,7 @@ def test_caller_overrides_win_over_typed_values(): def test_existing_request_options_preserved(): client = _FakeClient() - stream_speech_to_text( - client, language="en", request_options={"max_retries": 3} - ) + stream_speech_to_text(client, language="en", request_options={"max_retries": 3}) ro = client._stt.captured_request_options assert ro["max_retries"] == 3 assert ro["additional_query_parameters"]["language"] == "en" diff --git a/tests/custom/test_telemetry.py b/tests/custom/test_telemetry.py new file mode 100644 index 00000000..ee2bf4b5 --- /dev/null +++ b/tests/custom/test_telemetry.py @@ -0,0 +1,78 @@ +"""Anonymous opt-out telemetry: opt-out, anonymous id, no-PII payload, non-blocking.""" + +import smallestai.telemetry as telemetry + + +def test_opt_out_env_vars(monkeypatch): + monkeypatch.delenv("DO_NOT_TRACK", raising=False) + monkeypatch.setenv("SMALLESTAI_TELEMETRY", "0") + assert telemetry.is_enabled() is False + monkeypatch.setenv("SMALLESTAI_TELEMETRY", "1") + assert telemetry.is_enabled() is True + monkeypatch.setenv("DO_NOT_TRACK", "1") + assert telemetry.is_enabled() is False + + +def test_install_id_is_anonymous_and_persists(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + first = telemetry.install_id() + second = telemetry.install_id() + assert first == second + assert len(first) == 32 # a uuid4 hex, not an account id + + +def test_disabled_capture_sends_nothing(monkeypatch): + monkeypatch.setenv("SMALLESTAI_TELEMETRY", "0") + calls = [] + monkeypatch.setattr(telemetry, "_post", lambda e, p: calls.append(e)) + telemetry.capture("cli_invoked", {"command": "agent-crew"}) + assert calls == [] + + +def test_enabled_capture_payload_has_versions_and_no_secrets(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.delenv("SMALLESTAI_TELEMETRY", raising=False) + monkeypatch.delenv("DO_NOT_TRACK", raising=False) + + captured = {} + + def fake_post(event, props): + captured["event"] = event + captured["props"] = props + + class _ImmediateThread: + def __init__(self, target, args=(), daemon=None): + self._target, self._args = target, args + + def start(self): + self._target(*self._args) + + monkeypatch.setattr(telemetry, "_post", fake_post) + monkeypatch.setattr(telemetry.threading, "Thread", _ImmediateThread) + + telemetry.capture("cli_invoked", {"command": "agent-crew"}) + + assert captured["event"] == "cli_invoked" + props = captured["props"] + assert {"sdk_version", "python_version", "os"} <= set(props) + assert props["command"] == "agent-crew" + # never leak identifying/secret keys + blob = str(props).lower() + for banned in ("api_key", "sk_", "token", "phone", "prompt", "transcript"): + assert banned not in blob + + +def test_first_run_notice_shows_once(monkeypatch, tmp_path, capsys): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.delenv("SMALLESTAI_TELEMETRY", raising=False) + monkeypatch.delenv("DO_NOT_TRACK", raising=False) + telemetry.maybe_show_first_run_notice() + telemetry.maybe_show_first_run_notice() + out = capsys.readouterr().out + assert out.count("anonymous usage telemetry") == 1 + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"]) diff --git a/tests/custom/test_tools_framework.py b/tests/custom/test_tools_framework.py new file mode 100644 index 00000000..ae71c81a --- /dev/null +++ b/tests/custom/test_tools_framework.py @@ -0,0 +1,61 @@ +"""smallestai.tools: registry + Exa tool (lazy third-party dep, crew-pluggable).""" + +import asyncio + +import pytest + + +def test_registry_lists_and_resolves_exa(): + from smallestai.tools import ExaSearchTool, get_tool, list_tools + + tools = list_tools() + assert "exa_search" in tools + assert get_tool("exa_search") is ExaSearchTool + assert tools["exa_search"] is ExaSearchTool + + +def test_unknown_tool_raises_keyerror(): + from smallestai.tools import get_tool + + with pytest.raises(KeyError): + get_tool("does-not-exist") + + +def test_tool_plugs_into_crew_registry(): + from smallestai.atoms.crew.tools import ToolRegistry + from smallestai.tools import ExaSearchTool + + registry = ToolRegistry() + ExaSearchTool(api_key="x").register(registry) + names = {s["function"]["name"] for s in registry.get_schemas()} + assert "web_search" in names + + +def test_importing_tools_does_not_require_exa_py(): + # importing the package + constructing the tool must not need exa-py + import importlib + + importlib.import_module("smallestai.tools") + importlib.import_module("smallestai.tools.exa") + + +def test_exa_run_without_exa_py_raises_clear_error(): + from smallestai.tools import ExaSearchTool + + try: + import exa_py # type: ignore[import-not-found] # noqa: F401 + + installed = True + except ImportError: + installed = False + + if installed: + pytest.skip("exa-py is installed; the missing-dep path can't be exercised here") + + with pytest.raises(ImportError) as ei: + asyncio.run(ExaSearchTool(api_key="x").run(query="hello")) + assert "smallestai[exa]" in str(ei.value) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/custom/test_tts_helper.py b/tests/custom/test_tts_helper.py index ca5a004a..17cfd2ec 100644 --- a/tests/custom/test_tts_helper.py +++ b/tests/custom/test_tts_helper.py @@ -1,5 +1,6 @@ """waves TTS convenience helpers collapse the synthesize_* surface to two obvious entry points. Verified with a mocked client (no network).""" + import os import tempfile import unittest diff --git a/tests/custom/test_versioning_helper.py b/tests/custom/test_versioning_helper.py index 94cc3366..8d30839c 100644 --- a/tests/custom/test_versioning_helper.py +++ b/tests/custom/test_versioning_helper.py @@ -5,13 +5,14 @@ migration flag, and the base-revision case). Live coverage is in tests/velocity/v2_versioning_e2e.py. """ + from smallestai.atoms.helpers import ( - Versioning, - VersioningError, - MigrationRequiredError, - DraftConflictError, BaseRevisionUnavailableError, + DraftConflictError, + MigrationRequiredError, SecurityCheckFailedError, + Versioning, + VersioningError, ) from smallestai.atoms.helpers.versioning import _discriminate_conflict from smallestai.core.api_error import ApiError @@ -28,8 +29,7 @@ def test_migration_required(): def test_draft_conflict_rich_dev_shape(): e = _conflict( - {"data": {"conflict": {"expectedRevision": 2, "latestRevision": 3, - "diffs": [{"section": "workflow_prompt"}]}}} + {"data": {"conflict": {"expectedRevision": 2, "latestRevision": 3, "diffs": [{"section": "workflow_prompt"}]}}} ) assert isinstance(e, DraftConflictError) assert e.expected_revision == 2 diff --git a/tests/velocity/_env.py b/tests/velocity/_env.py index 447ab2e8..5f4e0418 100644 --- a/tests/velocity/_env.py +++ b/tests/velocity/_env.py @@ -3,6 +3,7 @@ Reads SMALLEST_API_KEY + base from the repo .env and builds a SmallestAI client pointed at the Velocity dev stack. NOT for CI — live target only. """ + import os import pathlib diff --git a/tests/velocity/field_drop_audit.py b/tests/velocity/field_drop_audit.py index bf0395c8..e2685d68 100644 --- a/tests/velocity/field_drop_audit.py +++ b/tests/velocity/field_drop_audit.py @@ -11,7 +11,9 @@ SMALLEST_API_KEY=... [SMALLEST_BASE_URL=...] python tests/velocity/field_drop_audit.py """ + from _env import client + from smallestai.atoms.helpers import as_page _NOISE = {"__v"} # mongo internals, not real SDK gaps @@ -27,7 +29,7 @@ def extras(model, path): for k in me: if k not in _NOISE: found.append(f"{path}.{k}") - for fname in (type(model).model_fields if hasattr(type(model), "model_fields") else {}): + for fname in type(model).model_fields if hasattr(type(model), "model_fields") else {}: v = getattr(model, fname, None) if hasattr(v, "model_extra"): found += extras(v, f"{path}.{fname}") diff --git a/tests/velocity/integration.py b/tests/velocity/integration.py index ded22bd1..43e75a50 100644 --- a/tests/velocity/integration.py +++ b/tests/velocity/integration.py @@ -6,7 +6,9 @@ SMALLEST_API_KEY=... [SMALLEST_BASE_URL=...] python tests/velocity/integration.py """ + from _env import client + from smallestai.atoms.helpers import as_page, require_id c = client() diff --git a/tests/velocity/smoke.py b/tests/velocity/smoke.py index da761b5c..17ecdf07 100644 --- a/tests/velocity/smoke.py +++ b/tests/velocity/smoke.py @@ -1,4 +1,5 @@ """Velocity smoke test via the shipped SDK: user -> create(first_message) -> get -> list.""" + from _env import client c = client() diff --git a/tests/velocity/triage.py b/tests/velocity/triage.py index 30f1d040..f3b8584c 100644 --- a/tests/velocity/triage.py +++ b/tests/velocity/triage.py @@ -4,10 +4,10 @@ verdict: FIXED (no longer a bug), BUG (reproduces), N/A (needs richer flow). owner: sdk / platform / spec / cli """ -import io + import os import warnings -import contextlib + from _env import client c = client() @@ -28,8 +28,11 @@ def agent_id_helper(): try: from smallestai import SmallestAI from smallestai.environment import SmallestAIEnvironment + base = os.environ["SMALLEST_BASE_URL"].rstrip("/") - env = SmallestAIEnvironment(atoms=f"{base}/atoms/v1", waves=base, waves_ws=base.replace("https", "wss"), payment=base) + env = SmallestAIEnvironment( + atoms=f"{base}/atoms/v1", waves=base, waves_ws=base.replace("https", "wss"), payment=base + ) saved = {k: os.environ.pop(k, None) for k in ("SMALLEST_AI_TOKEN",)} os.environ["SMALLEST_API_KEY"] = os.environ["SMALLEST_API_KEY"] c2 = SmallestAI(environment=env) # no api_key arg -> must read SMALLEST_API_KEY @@ -65,7 +68,12 @@ def agent_id_helper(): g = c.atoms.agents.get_agent_by_id(id="") d = getattr(g, "data", None) is_list = hasattr(d, "agents") - rec("#18", "BUG" if is_list else "FIXED", "sdk/platform", f"empty id -> {'list payload' if is_list else type(d).__name__}") + rec( + "#18", + "BUG" if is_list else "FIXED", + "sdk/platform", + f"empty id -> {'list payload' if is_list else type(d).__name__}", + ) except Exception as e: rec("#18", "FIXED", "sdk", f"raises {type(e).__name__} (good)") @@ -84,13 +92,19 @@ def agent_id_helper(): b = getattr(c.atoms.agents.create_a_new_agent(name="triage-dup-2026-06-18"), "data", None) rec("#21", "BUG", "platform", f"dup name allowed (a={a} b={b})") except Exception as e: - rec("#21", "FIXED", "platform", f"dup rejected: {getattr(e,'status_code','?')}") + rec("#21", "FIXED", "platform", f"dup rejected: {getattr(e, 'status_code', '?')}") except Exception as e: rec("#21", "ERR", "platform", str(e)[:90]) # --- #22 update_workflow_configuration still public --- -rec("#22", "BUG" if hasattr(c.atoms.agents, "update_workflow_configuration") else "FIXED", - "sdk", "update_workflow_configuration on public surface" if hasattr(c.atoms.agents, "update_workflow_configuration") else "hidden") +rec( + "#22", + "BUG" if hasattr(c.atoms.agents, "update_workflow_configuration") else "FIXED", + "sdk", + "update_workflow_configuration on public surface" + if hasattr(c.atoms.agents, "update_workflow_configuration") + else "hidden", +) # --- #24 pydantic serialization warning on list --- try: @@ -114,6 +128,7 @@ def agent_id_helper(): # --- #1 / #12 / #12a / #15 crew + helpers surface (import-level) --- try: from smallestai.atoms.crew.clients.openai import OpenAIClient + raised = False try: os.environ.pop("OPENAI_API_KEY", None) @@ -122,13 +137,24 @@ def agent_id_helper(): raised = True except Exception: raised = False - rec("#1", "FIXED" if raised else "BUG", "sdk", "OpenAIClient(api_key=None) raises" if raised else "no raise (warns + proceeds)") - rec("#12", "FIXED" if hasattr(OpenAIClient, "electron") else "BUG", "sdk", "electron() factory present" if hasattr(OpenAIClient, "electron") else "no electron() factory") + rec( + "#1", + "FIXED" if raised else "BUG", + "sdk", + "OpenAIClient(api_key=None) raises" if raised else "no raise (warns + proceeds)", + ) + rec( + "#12", + "FIXED" if hasattr(OpenAIClient, "electron") else "BUG", + "sdk", + "electron() factory present" if hasattr(OpenAIClient, "electron") else "no electron() factory", + ) except Exception as e: rec("#1", "ERR", "sdk", f"import crew openai failed: {str(e)[:80]}") try: import smallestai.atoms.helpers as h + names = [n for n in dir(h) if not n.startswith("_")] rec("#15", "BUG" if "Call" in names and "CallAnalytics" not in names else "FIXED", "sdk", f"helpers: {names}") except Exception as e: diff --git a/tests/velocity/v2_versioning_api_probe.py b/tests/velocity/v2_versioning_api_probe.py index c4f1eae7..042579fe 100644 --- a/tests/velocity/v2_versioning_api_probe.py +++ b/tests/velocity/v2_versioning_api_probe.py @@ -14,6 +14,7 @@ export E2E_AGENT_ID= # optional; omit to auto-create a throwaway agent python tests/velocity/v2_versioning_api_probe.py """ + import json import os import sys @@ -66,12 +67,16 @@ def main(): # 0. Create a throwaway agent if none was given. if not agent_id: - sc, b, _ = call("POST", "/agent", json={ - "name": "v2-api-probe", - "globalPrompt": "You are a probe agent.", - "firstMessage": "Hi, probe here.", - "language": {"switching": {"isEnabled": False}}, - }) + sc, b, _ = call( + "POST", + "/agent", + json={ + "name": "v2-api-probe", + "globalPrompt": "You are a probe agent.", + "firstMessage": "Hi, probe here.", + "language": {"switching": {"isEnabled": False}}, + }, + ) agent_id = b.get("data") created = True check(sc in (200, 201) and agent_id, "POST /agent (create throwaway)", f"http={sc} id={agent_id}") @@ -86,15 +91,22 @@ def main(): main_id = main["branch"]["_id"] # 2. Fork a throwaway branch off Main. - sc, b, _ = call("POST", f"/agent/{agent_id}/branches", json={"sourceBranchId": main_id, "name": f"probe-{os.getpid()}"}) + sc, b, _ = call( + "POST", f"/agent/{agent_id}/branches", json={"sourceBranchId": main_id, "name": f"probe-{os.getpid()}"} + ) fork = b.get("data", {}) fork_id = fork.get("_id") - check(sc in (200, 201) and fork_id, "POST /branches (fork)", f"http={sc} fork={fork_id} status={fork.get('status')}") + check( + sc in (200, 201) and fork_id, "POST /branches (fork)", f"http={sc} fork={fork_id} status={fork.get('status')}" + ) try: # 3. Edit the draft (camelCase config fields). - sc, b, _ = call("PUT", f"/agent/{agent_id}/branches/{fork_id}/draft", - json={"globalPrompt": "Probe prompt v1.", "firstMessage": "Hello from probe."}) + sc, b, _ = call( + "PUT", + f"/agent/{agent_id}/branches/{fork_id}/draft", + json={"globalPrompt": "Probe prompt v1.", "firstMessage": "Hello from probe."}, + ) check(sc == 200, "PUT /draft (globalPrompt + firstMessage)", f"http={sc}") # 4. Get the draft detail. @@ -119,8 +131,11 @@ def main(): if rev.get("status") == "published": break time.sleep(2) - check(rev.get("status") == "published", "publish -> revision published", - f"rev={rev_id} status={rev.get('status')} securityCheck={(rev.get('securityCheck') or {}).get('status')}") + check( + rev.get("status") == "published", + "publish -> revision published", + f"rev={rev_id} status={rev.get('status')} securityCheck={(rev.get('securityCheck') or {}).get('status')}", + ) show("Revision", rev, ["_id", "revisionNumber", "status", "label", "publishedByName", "promptScoreStale"]) # 6. Test call (webcall). @@ -131,7 +146,7 @@ def main(): # 7. Second edit + publish so we have two revisions (also gives restore an older target). call("PUT", f"/agent/{agent_id}/branches/{fork_id}/draft", json={"globalPrompt": "Probe prompt v2."}) sc, b, _ = call("POST", f"/agent/{agent_id}/branches/{fork_id}/draft/publish", json={"label": "probe v2"}) - check(sc in (200, 202), "POST /draft/publish (2nd)", f"http={sc} state={b.get('data',{}).get('state')}") + check(sc in (200, 202), "POST /draft/publish (2nd)", f"http={sc} state={b.get('data', {}).get('state')}") rev2_id = None deadline = time.monotonic() + 90 while time.monotonic() < deadline: @@ -148,32 +163,49 @@ def main(): sc, b, _ = call("GET", f"/agent/{agent_id}/diff", params={"a": rev_id, "b": rev2_id}) d = b.get("data", {}) check(sc == 200, "GET /diff (rev1 vs rev2)", f"http={sc}") - show("diff", {"unchangedSections": d.get("unchangedSections"), - "diffs": [x.get("section") for x in d.get("diffs", [])]}) + show( + "diff", + {"unchangedSections": d.get("unchangedSections"), "diffs": [x.get("section") for x in d.get("diffs", [])]}, + ) # 9. Optimistic-concurrency conflict (stale expectedRevision). call("PUT", f"/agent/{agent_id}/branches/{fork_id}/draft", json={"globalPrompt": "edit A"}) sc, gd, _ = call("GET", f"/agent/{agent_id}/branches/{fork_id}/draft") stale = gd.get("data", {}).get("latest", {}).get("draftRevision") - call("PUT", f"/agent/{agent_id}/branches/{fork_id}/draft", json={"expectedRevision": stale, "globalPrompt": "edit B"}) - sc, b, _ = call("PUT", f"/agent/{agent_id}/branches/{fork_id}/draft", json={"expectedRevision": stale, "globalPrompt": "edit C"}) + call( + "PUT", + f"/agent/{agent_id}/branches/{fork_id}/draft", + json={"expectedRevision": stale, "globalPrompt": "edit B"}, + ) + sc, b, _ = call( + "PUT", + f"/agent/{agent_id}/branches/{fork_id}/draft", + json={"expectedRevision": stale, "globalPrompt": "edit C"}, + ) check(sc == 409, "PUT /draft stale expectedRevision -> 409", f"http={sc}") show("conflict body", b) # 10. Restore the FIRST revision (older than head) -> new head revision. sc, b, _ = call("POST", f"/agent/{agent_id}/branches/{fork_id}/revisions/{rev_id}/restore") - check(sc == 200, "POST /revisions/{id}/restore (older revision)", f"http={sc} state={b.get('data',{}).get('state')}") + check( + sc == 200, + "POST /revisions/{id}/restore (older revision)", + f"http={sc} state={b.get('data', {}).get('state')}", + ) # 11. Make the fork live, then revert Main. sc, b, _ = call("POST", f"/agent/{agent_id}/branches/{fork_id}/live") - check(sc == 200, "POST /branches/{fork}/live", f"http={sc} isLive={b.get('data',{}).get('isLive')}") + check(sc == 200, "POST /branches/{fork}/live", f"http={sc} isLive={b.get('data', {}).get('isLive')}") sc, _, _ = call("POST", f"/agent/{agent_id}/branches/{main_id}/live") check(sc == 200, "POST /branches/{main}/live (revert)", f"http={sc}") # 12. v1 endpoint is deprecated under the branch model -> 409 migration. sc, b, hdr = call("POST", f"/agent/{agent_id}/drafts", json={}) - check(sc == 409 and b.get("error_type") == "versioning_v2_migration_required", - "v1 POST /drafts -> 409 migration", f"http={sc} error_type={b.get('error_type')} Deprecation={hdr.get('Deprecation')}") + check( + sc == 409 and b.get("error_type") == "versioning_v2_migration_required", + "v1 POST /drafts -> 409 migration", + f"http={sc} error_type={b.get('error_type')} Deprecation={hdr.get('Deprecation')}", + ) finally: sc, b, _ = call("POST", f"/agent/{agent_id}/branches/{fork_id}/archive") print(f"\ncleanup: archived fork {fork_id} -> http={sc} {b.get('data', b)}") diff --git a/tests/velocity/v2_versioning_e2e.py b/tests/velocity/v2_versioning_e2e.py index 24e4bb89..213a34cf 100644 --- a/tests/velocity/v2_versioning_e2e.py +++ b/tests/velocity/v2_versioning_e2e.py @@ -16,16 +16,16 @@ SMALLEST_API_KEY=sk_... SMALLEST_BASE_URL=https://api.dev.smallest.ai/atoms/v1 \ python tests/velocity/v2_versioning_e2e.py """ + import os import sys from smallestai import SmallestAI -from smallestai.environment import SmallestAIEnvironment from smallestai.atoms.helpers.versioning import ( - Versioning, DraftConflictError, - MigrationRequiredError, + Versioning, ) +from smallestai.environment import SmallestAIEnvironment KEY = os.environ.get("SMALLEST_API_KEY") BASE = os.environ.get("SMALLEST_BASE_URL") @@ -81,7 +81,9 @@ def main() -> int: # typed update_draft via helper ud = v.update_draft(AGENT, fork_id, global_prompt="E2E typed prompt", first_message="Hi from e2e") - check(ud is not None, "update_draft (typed kwargs)", f"draftRevision={getattr(ud.data,'draft_revision',None)}") + check( + ud is not None, "update_draft (typed kwargs)", f"draftRevision={getattr(ud.data, 'draft_revision', None)}" + ) # get_draft gd = v.branches.get_draft(id=AGENT, branch_id=fork_id) @@ -90,8 +92,11 @@ def main() -> int: # publish + wait_for_commit (helper handles scanning->poll) revision = v.publish_and_wait(AGENT, fork_id, label="e2e", timeout=90, poll_interval=2) - check(getattr(revision, "status", None) == "published", "publish_and_wait -> published", - f"rev={getattr(revision,'id',None)} securityCheck={getattr(getattr(revision,'security_check',None),'status',None)}") + check( + getattr(revision, "status", None) == "published", + "publish_and_wait -> published", + f"rev={getattr(revision, 'id', None)} securityCheck={getattr(getattr(revision, 'security_check', None), 'status', None)}", + ) # revisions list / get / history rl = v.revisions.list(id=AGENT, branch_id=fork_id) @@ -118,19 +123,25 @@ def main() -> int: conflict_raised = True # dev returns the rich data.conflict (diffs); prod returns lean errors (fields). has_detail = bool(e.diffs) or bool(getattr(e, "fields", [])) - check(has_detail, "DraftConflictError discriminated", - f"expected={e.expected_revision} latest={e.latest_revision} diffs={len(e.diffs)} fields={getattr(e,'fields',[])}") + check( + has_detail, + "DraftConflictError discriminated", + f"expected={e.expected_revision} latest={e.latest_revision} diffs={len(e.diffs)} fields={getattr(e, 'fields', [])}", + ) check(conflict_raised, "stale expected_revision raises DraftConflictError") # publish the pending draft edits as a 2nd revision, so restore has an # OLDER target than head (restoring the current head is a 409 no-op). rev2 = v.publish_and_wait(AGENT, fork_id, label="e2e-2", timeout=90, poll_interval=2) - check(getattr(rev2, "status", None) == "published", "second publish -> published", - f"rev={getattr(rev2,'id',None)}") + check( + getattr(rev2, "status", None) == "published", + "second publish -> published", + f"rev={getattr(rev2, 'id', None)}", + ) # restore the FIRST revision (older than head) -> new head revision rr = v.revisions.restore(id=AGENT, branch_id=fork_id, revision_id=rid) - check(rr.data is not None, "revisions.restore (older revision)", f"state={getattr(rr.data,'state',None)}") + check(rr.data is not None, "revisions.restore (older revision)", f"state={getattr(rr.data, 'state', None)}") ml = v.branches.make_live(id=AGENT, branch_id=fork_id) check(ml.data is not None, "make_live (fork)", "") v.branches.make_live(id=AGENT, branch_id=main_id) # revert diff --git a/tests/wire/test_atoms_account.py b/tests/wire/test_atoms_account.py new file mode 100644 index 00000000..6a7d2326 --- /dev/null +++ b/tests/wire/test_atoms_account.py @@ -0,0 +1,19 @@ +from .conftest import get_client, verify_request_count + + +def test_atoms_account_get_account_details() -> None: + """Test getAccountDetails endpoint with WireMock""" + test_id = "atoms.account.get_account_details.0" + client = get_client(test_id) + client.atoms.account.get_account_details() + verify_request_count(test_id, "GET", "/account/get-account-details", None, 1) + + +def test_atoms_account_update_organization_name() -> None: + """Test updateOrganizationName endpoint with WireMock""" + test_id = "atoms.account.update_organization_name.0" + client = get_client(test_id) + client.atoms.account.update_organization_name( + name="Acme Inc.", + ) + verify_request_count(test_id, "PUT", "/account/update-org-name", None, 1) diff --git a/tests/wire/test_atoms_campaigns.py b/tests/wire/test_atoms_campaigns.py index abf61846..e8d1ef99 100644 --- a/tests/wire/test_atoms_campaigns.py +++ b/tests/wire/test_atoms_campaigns.py @@ -59,3 +59,24 @@ def test_atoms_campaigns_pause() -> None: id="id", ) verify_request_count(test_id, "POST", "/campaign/id/pause", None, 1) + + +def test_atoms_campaigns_export_campaign_results_by_audience_member() -> None: + """Test exportCampaignResultsByAudienceMember endpoint with WireMock""" + test_id = "atoms.campaigns.export_campaign_results_by_audience_member.0" + client = get_client(test_id) + client.atoms.campaigns.export_campaign_results_by_audience_member( + id="6a75935452c6e5eceaa16edf", + ) + verify_request_count(test_id, "GET", "/campaign/6a75935452c6e5eceaa16edf/export/by-audience-member", None, 1) + + +def test_atoms_campaigns_export_campaign_logs() -> None: + """Test exportCampaignLogs endpoint with WireMock""" + test_id = "atoms.campaigns.export_campaign_logs.0" + client = get_client(test_id) + for _ in client.atoms.campaigns.export_campaign_logs( + id="id", + ): + pass + verify_request_count(test_id, "GET", "/campaign/id/logs/export", None, 1) diff --git a/tests/wire/test_atoms_organization.py b/tests/wire/test_atoms_organization.py deleted file mode 100644 index b00e73d2..00000000 --- a/tests/wire/test_atoms_organization.py +++ /dev/null @@ -1,9 +0,0 @@ -from .conftest import get_client, verify_request_count - - -def test_atoms_organization_get_organization_details() -> None: - """Test getOrganizationDetails endpoint with WireMock""" - test_id = "atoms.organization.get_organization_details.0" - client = get_client(test_id) - client.atoms.organization.get_organization_details() - verify_request_count(test_id, "GET", "/organization", None, 1) diff --git a/tests/wire/test_atoms_user.py b/tests/wire/test_atoms_user.py index aa8eac3d..bae617e9 100644 --- a/tests/wire/test_atoms_user.py +++ b/tests/wire/test_atoms_user.py @@ -7,3 +7,11 @@ def test_atoms_user_get_user_details() -> None: client = get_client(test_id) client.atoms.user.get_user_details() verify_request_count(test_id, "GET", "/user", None, 1) + + +def test_atoms_user_get_subscription() -> None: + """Test get_subscription endpoint with WireMock""" + test_id = "atoms.user.get_subscription.0" + client = get_client(test_id) + client.atoms.user.get_subscription() + verify_request_count(test_id, "GET", "/user/subscription", None, 1) diff --git a/tests/wire/test_atoms_webCall.py b/tests/wire/test_atoms_webCall.py new file mode 100644 index 00000000..47b32c12 --- /dev/null +++ b/tests/wire/test_atoms_webCall.py @@ -0,0 +1,21 @@ +from .conftest import get_client, verify_request_count + + +def test_atoms_webCall_start_web_chat_conversation() -> None: + """Test startWebChatConversation endpoint with WireMock""" + test_id = "atoms.web_call.start_web_chat_conversation.0" + client = get_client(test_id) + client.atoms.web_call.start_web_chat_conversation( + agent_id="6a75935452c6e5eceaa16edf", + ) + verify_request_count(test_id, "POST", "/conversation/chat", None, 1) + + +def test_atoms_webCall_start_web_call_conversation() -> None: + """Test startWebCallConversation endpoint with WireMock""" + test_id = "atoms.web_call.start_web_call_conversation.0" + client = get_client(test_id) + client.atoms.web_call.start_web_call_conversation( + agent_id="6a75935452c6e5eceaa16edf", + ) + verify_request_count(test_id, "POST", "/conversation/webcall", None, 1) diff --git a/tests/wire/test_waves_analytics.py b/tests/wire/test_waves_analytics.py new file mode 100644 index 00000000..a1ae08d5 --- /dev/null +++ b/tests/wire/test_waves_analytics.py @@ -0,0 +1,124 @@ +import datetime + +from .conftest import get_client, verify_request_count + + +def test_waves_analytics_list_streaming_speech_to_text_logs() -> None: + """Test listStreamingSpeechToTextLogs endpoint with WireMock""" + test_id = "waves.analytics.list_streaming_speech_to_text_logs.0" + client = get_client(test_id) + client.waves.analytics.list_streaming_speech_to_text_logs() + verify_request_count(test_id, "GET", "/waves/v1/analytics/asr/logs", None, 1) + + +def test_waves_analytics_delete_streaming_speech_to_text_history() -> None: + """Test deleteStreamingSpeechToTextHistory endpoint with WireMock""" + test_id = "waves.analytics.delete_streaming_speech_to_text_history.0" + client = get_client(test_id) + client.waves.analytics.delete_streaming_speech_to_text_history( + request_id="3eea9859-609b-45c5-8a25-0337c9763c96", + ) + verify_request_count( + test_id, "DELETE", "/waves/v1/analytics/asr/history/3eea9859-609b-45c5-8a25-0337c9763c96", None, 1 + ) + + +def test_waves_analytics_get_streaming_speech_to_text_usage_timeseries() -> None: + """Test getStreamingSpeechToTextUsageTimeseries endpoint with WireMock""" + test_id = "waves.analytics.get_streaming_speech_to_text_usage_timeseries.0" + client = get_client(test_id) + client.waves.analytics.get_streaming_speech_to_text_usage_timeseries( + from_=datetime.datetime.fromisoformat("2026-08-01T00:00:00+00:00"), + to=datetime.datetime.fromisoformat("2026-08-07T00:00:00+00:00"), + ) + verify_request_count( + test_id, + "GET", + "/waves/v1/analytics/asr/usage/timeseries", + {"from": "2026-08-01T00:00:00Z", "to": "2026-08-07T00:00:00Z"}, + 1, + ) + + +def test_waves_analytics_list_text_to_speech_logs() -> None: + """Test listTextToSpeechLogs endpoint with WireMock""" + test_id = "waves.analytics.list_text_to_speech_logs.0" + client = get_client(test_id) + client.waves.analytics.list_text_to_speech_logs() + verify_request_count(test_id, "GET", "/waves/v1/analytics/tts/logs", None, 1) + + +def test_waves_analytics_get_text_to_speech_usage_timeseries() -> None: + """Test getTextToSpeechUsageTimeseries endpoint with WireMock""" + test_id = "waves.analytics.get_text_to_speech_usage_timeseries.0" + client = get_client(test_id) + client.waves.analytics.get_text_to_speech_usage_timeseries( + from_=datetime.datetime.fromisoformat("2026-08-01T00:00:00+00:00"), + to=datetime.datetime.fromisoformat("2026-08-07T00:00:00+00:00"), + ) + verify_request_count( + test_id, + "GET", + "/waves/v1/analytics/tts/usage/timeseries", + {"from": "2026-08-01T00:00:00Z", "to": "2026-08-07T00:00:00Z"}, + 1, + ) + + +def test_waves_analytics_get_text_to_speech_credits_timeseries() -> None: + """Test getTextToSpeechCreditsTimeseries endpoint with WireMock""" + test_id = "waves.analytics.get_text_to_speech_credits_timeseries.0" + client = get_client(test_id) + client.waves.analytics.get_text_to_speech_credits_timeseries( + from_=datetime.datetime.fromisoformat("2026-08-01T00:00:00+00:00"), + to=datetime.datetime.fromisoformat("2026-08-07T00:00:00+00:00"), + ) + verify_request_count( + test_id, + "GET", + "/waves/v1/analytics/tts/usage/credits/timeseries", + {"from": "2026-08-01T00:00:00Z", "to": "2026-08-07T00:00:00Z"}, + 1, + ) + + +def test_waves_analytics_get_text_to_speech_concurrency_timeseries() -> None: + """Test getTextToSpeechConcurrencyTimeseries endpoint with WireMock""" + test_id = "waves.analytics.get_text_to_speech_concurrency_timeseries.0" + client = get_client(test_id) + client.waves.analytics.get_text_to_speech_concurrency_timeseries( + from_=datetime.datetime.fromisoformat("2026-08-01T00:00:00+00:00"), + to=datetime.datetime.fromisoformat("2026-08-07T00:00:00+00:00"), + ) + verify_request_count( + test_id, + "GET", + "/waves/v1/analytics/tts/concurrency/timeseries", + {"from": "2026-08-01T00:00:00Z", "to": "2026-08-07T00:00:00Z"}, + 1, + ) + + +def test_waves_analytics_get_text_to_speech_websocket_connections_timeseries() -> None: + """Test getTextToSpeechWebsocketConnectionsTimeseries endpoint with WireMock""" + test_id = "waves.analytics.get_text_to_speech_websocket_connections_timeseries.0" + client = get_client(test_id) + client.waves.analytics.get_text_to_speech_websocket_connections_timeseries( + from_=datetime.datetime.fromisoformat("2026-08-01T00:00:00+00:00"), + to=datetime.datetime.fromisoformat("2026-08-07T00:00:00+00:00"), + ) + verify_request_count( + test_id, + "GET", + "/waves/v1/analytics/tts/ws-connections/timeseries", + {"from": "2026-08-01T00:00:00Z", "to": "2026-08-07T00:00:00Z"}, + 1, + ) + + +def test_waves_analytics_list_webhook_logs() -> None: + """Test listWebhookLogs endpoint with WireMock""" + test_id = "waves.analytics.list_webhook_logs.0" + client = get_client(test_id) + client.waves.analytics.list_webhook_logs() + verify_request_count(test_id, "GET", "/waves/v1/analytics/webhooks/logs", None, 1) diff --git a/tests/wire/test_waves_ops.py b/tests/wire/test_waves_ops.py new file mode 100644 index 00000000..7057f4d0 --- /dev/null +++ b/tests/wire/test_waves_ops.py @@ -0,0 +1,9 @@ +from .conftest import get_client, verify_request_count + + +def test_waves_ops_get_waves_health() -> None: + """Test getWavesHealth endpoint with WireMock""" + test_id = "waves.ops.get_waves_health.0" + client = get_client(test_id) + client.waves.ops.get_waves_health() + verify_request_count(test_id, "GET", "/waves/v1/health", None, 1) diff --git a/tests/wire/test_waves_postCallAnalysis.py b/tests/wire/test_waves_postCallAnalysis.py new file mode 100644 index 00000000..43d7fc2b --- /dev/null +++ b/tests/wire/test_waves_postCallAnalysis.py @@ -0,0 +1,30 @@ +from .conftest import get_client, verify_request_count + +from smallestai.waves import DispositionMetric + + +def test_waves_postCallAnalysis_analyze() -> None: + """Test analyze endpoint with WireMock""" + test_id = "waves.post_call_analysis.analyze.0" + client = get_client(test_id) + client.waves.post_call_analysis.analyze( + transcript="User: I want a refund for my torn jacket. Agent: I have logged your complaint and issued a full refund of $80.", + disposition_metrics=[ + DispositionMetric( + identifier="refund_issued", + disposition_metric_prompt="Was a refund issued to the customer?", + disposition_metric_type="BOOLEAN", + ) + ], + ) + verify_request_count(test_id, "POST", "/waves/v1/pca", None, 1) + + +def test_waves_postCallAnalysis_generate() -> None: + """Test generate endpoint with WireMock""" + test_id = "waves.post_call_analysis.generate.0" + client = get_client(test_id) + client.waves.post_call_analysis.generate( + prompt="Say hello in exactly three words.", + ) + verify_request_count(test_id, "POST", "/waves/v1/pca/generate", None, 1) diff --git a/tests/wire/test_waves_voices.py b/tests/wire/test_waves_voices.py new file mode 100644 index 00000000..33894e70 --- /dev/null +++ b/tests/wire/test_waves_voices.py @@ -0,0 +1,9 @@ +from .conftest import get_client, verify_request_count + + +def test_waves_voices_get_all_voice_models() -> None: + """Test getAllVoiceModels endpoint with WireMock""" + test_id = "waves.voices.get_all_voice_models.0" + client = get_client(test_id) + client.waves.voices.get_all_voice_models() + verify_request_count(test_id, "GET", "/waves/v1/voice/get-all-models", None, 1) diff --git a/wiremock/wiremock-mappings.json b/wiremock/wiremock-mappings.json index 97836e7e..0083930a 100644 --- a/wiremock/wiremock-mappings.json +++ b/wiremock/wiremock-mappings.json @@ -33,10 +33,10 @@ "postServeActions": [] }, { - "id": "3142d983-0755-42b2-a57e-8e870c673b42", - "name": "Get organization details - default", + "id": "cf9588a0-f44b-4cdc-b7d1-7fb340f777d4", + "name": "Get subscription and plan limits - default", "request": { - "urlPathTemplate": "/organization", + "urlPathTemplate": "/user/subscription", "method": "GET", "headers": { "Authorization": { @@ -46,12 +46,12 @@ }, "response": { "status": 200, - "body": "{\n \"status\": true,\n \"data\": {\n \"_id\": \"_id\",\n \"name\": \"name\",\n \"members\": [\n {}\n ],\n \"subscription\": {\n \"planId\": \"planId\"\n }\n }\n}", + "body": "{\n \"status\": true,\n \"data\": {\n \"_id\": \"_id\",\n \"organization\": \"organization\",\n \"planId\": \"planId\",\n \"totalCredits\": 1.1,\n \"currentCredits\": 1.1,\n \"renewalDate\": \"2024-01-15T09:30:00Z\",\n \"subscriptionActive\": true,\n \"onPremEnabled\": true,\n \"limits\": {\n \"agents\": 1,\n \"campaigns\": 1,\n \"numbers\": 1,\n \"dailyCalls\": 1,\n \"concurrentCalls\": 1,\n \"knowledgeBaseItemLimit\": 1,\n \"knowledgeBaseLimits\": 1\n },\n \"features\": {\n \"key\": true\n }\n }\n}", "headers": { "Content-Type": "application/json" } }, - "uuid": "3142d983-0755-42b2-a57e-8e870c673b42", + "uuid": "cf9588a0-f44b-4cdc-b7d1-7fb340f777d4", "persistent": true, "priority": 3, "metadata": { @@ -5441,6 +5441,661 @@ } } } + }, + { + "id": "ac0318ee-6eb7-47a9-bdfe-98c96979e8f9", + "name": "Export campaign call logs - default", + "request": { + "urlPathTemplate": "/campaign/{id}/logs/export", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "pathParameters": { + "id": { + "equalTo": "id" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"key\": \"value\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "ac0318ee-6eb7-47a9-bdfe-98c96979e8f9", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "25aa0a48-480b-41f2-b5db-f91d723cf49e", + "name": "Get account details - User in two orgs", + "request": { + "urlPathTemplate": "/account/get-account-details", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"userId\": \"69561896a37fd214b9a8d33a\",\n \"email\": \"ada@example.com\",\n \"firstName\": \"Ada\",\n \"lastName\": \"Lovelace\",\n \"picture\": \"https://lh3.googleusercontent.com/a/avatar.jpg\",\n \"hasOnboarded\": true,\n \"organizations\": [\n {\n \"orgId\": \"69561896a37fd214b9a8d33c\",\n \"name\": \"Acme Inc.\",\n \"ownerEmail\": \"ada@example.com\",\n \"roleId\": 0,\n \"onPremEnabled\": false\n },\n {\n \"orgId\": \"698076af5c1b62205ff731b8\",\n \"name\": \"Contoso\",\n \"ownerEmail\": \"grace@example.com\",\n \"roleId\": 1,\n \"onPremEnabled\": false\n }\n ]\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "25aa0a48-480b-41f2-b5db-f91d723cf49e", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "ff76103e-df42-452d-a686-ba4e93f2b8f0", + "name": "Update the organization name - Rename accepted", + "request": { + "urlPathTemplate": "/account/update-org-name", + "method": "PUT", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"success\": true,\n \"name\": \"Acme Inc.\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "ff76103e-df42-452d-a686-ba4e93f2b8f0", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "d321915a-4bbe-4c80-929d-ecee246d2317", + "name": "Start a web chat session - LiveKit session ready for the browser client", + "request": { + "urlPathTemplate": "/conversation/chat", + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"status\": true,\n \"data\": {\n \"token\": \"eyJhbGciOiJIUzI1NiJ9.eyJtZXRhZGF0YSI6IntcIm9yZ2FuaXphdGlvbklkXCI6XCI2OTU2MTg5NmEzN2ZkMjE0YjlhOGQzM2NcIn0iLCJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6ImRlZTkyMjNhLTQ3NTEtNDlhOS1iY2JjLWNiMzVjNWE4MzcxZCJ9LCJleHAiOjE3ODYxNTk1NjR9.Tn9KCxhnmnjAFQPRWP6XMkjKYnhBiISI6VqUOIqOsRQ\",\n \"roomName\": \"dee9223a-4751-49a9-bcbc-cb35c5a8371d\",\n \"host\": \"wss://atoms-prod-evcaw70g.livekit.cloud\",\n \"conversationId\": \"IyF-GCRiprATI5rx4lIdvg\",\n \"callId\": \"CALL-1786159503332-8387f2\"\n }\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "d321915a-4bbe-4c80-929d-ecee246d2317", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "1b080d01-f762-472d-989c-f969b74cf155", + "name": "Start a web call session - LiveKit session ready for the browser client", + "request": { + "urlPathTemplate": "/conversation/webcall", + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"status\": true,\n \"data\": {\n \"token\": \"eyJhbGciOiJIUzI1NiJ9.eyJtZXRhZGF0YSI6IntcIm9yZ2FuaXphdGlvbklkXCI6XCI2OTU2MTg5NmEzN2ZkMjE0YjlhOGQzM2NcIn0iLCJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6IjIwOWNkOGM3LTdmZjQtNGFmYy1iNWQ1LWY3MDQ0OGI1Y2UwNyJ9LCJleHAiOjE3ODYxNTk1ODl9.jf5NM4fH79_d6iBb2xTvX5JIo_PmVpjeXWOugoDLQVc\",\n \"roomName\": \"209cd8c7-7ff4-4afc-b5d5-f70448b5ce07\",\n \"host\": \"wss://atoms-prod-evcaw70g.livekit.cloud\",\n \"conversationId\": \"w3fmaal3fKnzNQJ_CHfosw\",\n \"callId\": \"CALL-1786159527700-838803\"\n }\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "1b080d01-f762-472d-989c-f969b74cf155", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "a170e63a-859e-4e90-b820-bee20d99fbde", + "name": "Post-call analysis - default", + "request": { + "urlPathTemplate": "/waves/v1/pca", + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"summary\": \"The agent logged the customer's complaint and issued a full refund of $80 for the torn jacket.\",\n \"dispositionMetrics\": [\n {\n \"identifier\": \"refund_issued\",\n \"value\": \"true\",\n \"confidence\": 1,\n \"reasoning\": \"Agent: I have logged your complaint and issued a full refund of $80.\"\n }\n ]\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "a170e63a-859e-4e90-b820-bee20d99fbde", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "851d07de-767a-4001-892b-b7811ad9c233", + "name": "Generate - default", + "request": { + "urlPathTemplate": "/waves/v1/pca/generate", + "method": "POST", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"output\": \"Hello there.\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "851d07de-767a-4001-892b-b7811ad9c233", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "19c6ee0f-0d6a-4f5a-a0de-8897b88c6e72", + "name": "Get all voices - default", + "request": { + "urlPathTemplate": "/waves/v1/voice/get-all-models", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"voices\": [\n {\n \"_id\": \"6a0b3c06e6e0b1a48dee8151\",\n \"voiceId\": \"mishka\",\n \"displayName\": \"Mishka\",\n \"description\": \"A youthful Indian female voice with an inviting tone and smooth, expressive delivery.\",\n \"audioPreview\": \"https://d4a5s42qh2351.cloudfront.net/waves-audio-previews/mishka.wav\",\n \"tags\": {\n \"age\": \"young\",\n \"emotions\": [\n \"emotions\"\n ],\n \"language\": [\n \"english\",\n \"hindi\",\n \"marathi\",\n \"gujarati\",\n \"punjabi\",\n \"bengali\",\n \"odia\",\n \"tamil\",\n \"telugu\"\n ]\n }\n }\n ]\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "19c6ee0f-0d6a-4f5a-a0de-8897b88c6e72", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "9194d4c9-f2e2-4733-b6f7-6aad6be88d10", + "name": "List STT (ASR) request logs - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/asr/logs", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"status\": \"success\",\n \"data\": [\n {\n \"request_id\": \"3eea9859-609b-45c5-8a25-0337c9763c96\",\n \"timestamp\": \"2026-08-07 08:17:25\",\n \"request_type\": \"ws\",\n \"model\": \"pulse\",\n \"language\": \"en\",\n \"duration\": 44.03,\n \"file_size\": \"0\",\n \"transcription\": \"hello what are your plans today\",\n \"word_timestamps\": false,\n \"diarize\": false,\n \"redact_pii\": false,\n \"redact_pci\": false,\n \"numerals\": \"auto\",\n \"keywords\": \"\",\n \"emotion_detection\": false,\n \"gender_detection\": false,\n \"emotions\": \"\",\n \"gender\": \"\",\n \"webhook_url\": \"\"\n }\n ],\n \"page\": 1,\n \"pageSize\": 50,\n \"totalCount\": 128,\n \"totalPages\": 3\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "9194d4c9-f2e2-4733-b6f7-6aad6be88d10", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "8bb0681f-645d-4bc3-b77d-fcd1684da9f5", + "name": "Delete an STT request from history - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/asr/history/{request_id}", + "method": "DELETE", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "pathParameters": { + "request_id": { + "equalTo": "3eea9859-609b-45c5-8a25-0337c9763c96" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"status\": \"success\"\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "8bb0681f-645d-4bc3-b77d-fcd1684da9f5", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + }, + { + "id": "c69e9b5a-a9ec-446d-a729-238ead77719d", + "name": "STT usage timeseries - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/asr/usage/timeseries", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "queryParameters": { + "from": { + "equalTo": "2026-08-01T00:00:00Z" + }, + "to": { + "equalTo": "2026-08-07T00:00:00Z" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"timestamps\": [\n \"2026-08-05T00:00:00Z\",\n \"2026-08-06T00:00:00Z\",\n \"2026-08-07T00:00:00Z\"\n ],\n \"values\": [\n 412,\n 388,\n 501\n ],\n \"totalRequestCount\": 1301\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "c69e9b5a-a9ec-446d-a729-238ead77719d", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "3e8e986a-ced4-4c0e-8a07-953d484a6510", + "name": "List TTS request logs - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/tts/logs", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"status\": \"success\",\n \"data\": [\n {\n \"timestamp\": \"2026-08-07 08:17:20\",\n \"request_type\": \"ws\",\n \"model\": \"lightning-v3.1-pro\",\n \"voice_id\": \"blake\",\n \"language\": \"en\",\n \"text\": \"eight, seven, three, six, four. Just to confirm, is that correct?\",\n \"text_len\": 65,\n \"credits\": 0,\n \"speed\": 1,\n \"sample_rate\": 8000,\n \"output_format\": \"pcm\",\n \"enhancement\": 1,\n \"similarity\": 0\n }\n ],\n \"page\": 1,\n \"pageSize\": 50,\n \"totalCount\": 4212,\n \"totalPages\": 85\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "3e8e986a-ced4-4c0e-8a07-953d484a6510", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "0ee976c0-d6a9-4806-a77c-01fe0e6105ba", + "name": "TTS usage timeseries - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/tts/usage/timeseries", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "queryParameters": { + "from": { + "equalTo": "2026-08-01T00:00:00Z" + }, + "to": { + "equalTo": "2026-08-07T00:00:00Z" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"timestamps\": [\n \"2026-08-05T00:00:00Z\",\n \"2026-08-06T00:00:00Z\",\n \"2026-08-07T00:00:00Z\"\n ],\n \"values\": [\n 412,\n 388,\n 501\n ],\n \"totalRequestCount\": 1301\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "0ee976c0-d6a9-4806-a77c-01fe0e6105ba", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "91f75e6b-9f80-4a56-805f-2ef4b873a3d5", + "name": "TTS credit-consumption timeseries - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/tts/usage/credits/timeseries", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "queryParameters": { + "from": { + "equalTo": "2026-08-01T00:00:00Z" + }, + "to": { + "equalTo": "2026-08-07T00:00:00Z" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"timestamps\": [\n \"2026-08-05T00:00:00Z\",\n \"2026-08-06T00:00:00Z\",\n \"2026-08-07T00:00:00Z\"\n ],\n \"values\": [\n 124.5,\n 96.2,\n 141.8\n ],\n \"totalCredits\": 362.5\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "91f75e6b-9f80-4a56-805f-2ef4b873a3d5", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "fcf0227b-87cd-4cbf-a436-783a7043960d", + "name": "TTS concurrency timeseries - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/tts/concurrency/timeseries", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "queryParameters": { + "from": { + "equalTo": "2026-08-01T00:00:00Z" + }, + "to": { + "equalTo": "2026-08-07T00:00:00Z" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"timestamps\": [\n \"2026-08-07T08:00:00Z\",\n \"2026-08-07T09:00:00Z\",\n \"2026-08-07T10:00:00Z\"\n ],\n \"values\": [\n 3,\n 5,\n 4\n ]\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "fcf0227b-87cd-4cbf-a436-783a7043960d", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "bb908120-26d4-4fff-8e0d-54d6f26a0392", + "name": "TTS WebSocket-connection timeseries - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/tts/ws-connections/timeseries", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "queryParameters": { + "from": { + "equalTo": "2026-08-01T00:00:00Z" + }, + "to": { + "equalTo": "2026-08-07T00:00:00Z" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"timestamps\": [\n \"2026-08-07T08:00:00Z\",\n \"2026-08-07T09:00:00Z\",\n \"2026-08-07T10:00:00Z\"\n ],\n \"values\": [\n 3,\n 5,\n 4\n ]\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "bb908120-26d4-4fff-8e0d-54d6f26a0392", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "4e213469-a966-4e92-9617-0be173896ada", + "name": "List webhook delivery logs - default", + "request": { + "urlPathTemplate": "/waves/v1/analytics/webhooks/logs", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"status\": \"success\",\n \"data\": [\n {\n \"org_id\": \"69561896a37fd214b9a8d33c\",\n \"request_id\": \"36e977cf-d027-4fe2-b9ae-733d4f26c239\",\n \"webhook_url\": \"https://your-app.example.com/webhooks/asr\",\n \"method\": \"POST\",\n \"event_type\": \"asr.completed\",\n \"status\": \"failed\",\n \"attempt\": 10,\n \"response_status_code\": 503,\n \"error\": \"Request failed with status code 503\",\n \"timestamp\": \"2026-08-06 10:37:27\",\n \"duration_ms\": 819\n }\n ],\n \"page\": 1,\n \"pageSize\": 50,\n \"totalCount\": 87,\n \"totalPages\": 2\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "4e213469-a966-4e92-9617-0be173896ada", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "296104e1-411a-4cb2-8642-f60b2d7f539c", + "name": "Health check - default", + "request": { + "urlPathTemplate": "/waves/v1/health", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"status\": \"ok\",\n \"mongo\": {\n \"status\": \"connected\"\n },\n \"redis\": {\n \"status\": \"connected\"\n },\n \"rabbitmq\": {\n \"status\": \"connected\"\n }\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "296104e1-411a-4cb2-8642-f60b2d7f539c", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "5e33fd43-e552-4381-a9d6-7be6f6cbc79a", + "name": "Export campaign results grouped by audience member - default", + "request": { + "urlPathTemplate": "/campaign/{id}/export/by-audience-member", + "method": "GET", + "headers": { + "Authorization": { + "matches": "Bearer .+" + } + }, + "pathParameters": { + "id": { + "equalTo": "6a75935452c6e5eceaa16edf" + } + } + }, + "response": { + "status": 200, + "body": "{\n \"campaign\": \"campaign\",\n \"exportedAt\": \"2024-01-15T09:30:00Z\",\n \"totalAudienceMembers\": 1,\n \"data\": [\n {\n \"audienceMemberId\": \"audienceMemberId\",\n \"audienceMemberData\": {\n \"key\": \"value\"\n },\n \"phoneNumber\": \"phoneNumber\",\n \"originalCall\": {\n \"key\": \"value\"\n },\n \"retryAttempts\": [\n {\n \"key\": \"value\"\n }\n ],\n \"totalAttempts\": 1,\n \"finalStatus\": \"finalStatus\",\n \"totalCallDuration\": 1.1,\n \"totalCallCost\": 1.1\n }\n ]\n}", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "5e33fd43-e552-4381-a9d6-7be6f6cbc79a", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } } ], "meta": {