diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7aaf4aa..40cc5f2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -35,5 +35,5 @@ What should happen instead? ## Logs -Paste relevant logs with API keys, webhook secrets, phone numbers, and other +Paste relevant logs with API keys, phone numbers, and other private data removed. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 3a0c6cf..6ffdf9f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -20,12 +20,14 @@ Show the SDK API you would like to call. ## Scope -Is this within the Phase 1 server SDK scope? +Is this within the supported server SDK scope? - Create/read calls - Poll call results - List call events -- Verify webhooks +- List/read published Goals +- Create/poll Goal Runs +- Parse finalized terminal webhook events ## Alternatives diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2e65434..2f34379 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,7 +4,7 @@ Describe the SDK behavior, documentation, or release workflow change. ## Checklist -- [ ] I kept this change within the Phase 1 server SDK scope. +- [ ] I kept this change within the supported server SDK scope. - [ ] I did not add browser/client-side patterns that expose CALL-E API keys. - [ ] I updated tests, examples, or docs when behavior changed. - [ ] I ran the relevant local checks. diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index da5ce0d..0cd524b 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -84,13 +84,24 @@ jobs: run: | set -euo pipefail + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" if [ "${{ inputs.repository }}" = "pypi" ]; then - url="https://pypi.org/pypi/calle-ai/json" + url="https://pypi.org/pypi/calle-ai/${version}/json" else - url="https://test.pypi.org/pypi/calle-ai/json" + url="https://test.pypi.org/pypi/calle-ai/${version}/json" fi - curl --fail --silent --show-error "$url" >/dev/null + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if curl --fail --silent --show-error "$url" >/dev/null; then + exit 0 + fi + + echo "Package version metadata is not visible yet, retrying in 10s..." + sleep 10 + done + + echo "::error::Published package version metadata did not become visible in time." + exit 1 - name: Smoke test published package install run: | @@ -103,13 +114,36 @@ jobs: . "$smoke_dir/.venv/bin/activate" python -m pip install --upgrade pip - if [ "${{ inputs.repository }}" = "pypi" ]; then - python -m pip install "calle-ai==$version" - else - python -m pip install \ + installed=false + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if [ "${{ inputs.repository }}" = "pypi" ]; then + if python -m pip install "calle-ai==$version"; then + installed=true + break + fi + elif python -m pip install \ --index-url https://test.pypi.org/simple/ \ --extra-index-url https://pypi.org/simple \ - "calle-ai==$version" + "calle-ai==$version"; then + installed=true + break + fi + + echo "Package install is not available yet, retrying in 10s..." + sleep 10 + done + + if [ "$installed" != "true" ]; then + echo "::error::Published package install did not become available in time." + exit 1 fi - python -c 'from calle import CalleClient; print(CalleClient)' + python - <<'PY' + from calle import CalleClient + from calle.generated.models import Goal, GoalRun + + client = CalleClient(api_key="smoke") + assert callable(client.goals.run_and_wait) + client.close() + print(CalleClient, Goal, GoalRun) + PY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 519fdf9..1b41d2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,14 +17,22 @@ export CALLE_BASE_URL="https://api.heycall-e.com" export CALLE_EXAMPLE_PHONE="+14155550100" uv run python examples/create_and_wait.py -export CALLE_WEBHOOK_SECRET="whsec_test_key" +export CALLE_BASE_URL="https://test-api.heycall-e.com" +export CALLE_GOAL_ID="" +export CALLE_GOAL_PHONE="" +export CALLE_GOAL_VARIABLES='{"name":"Alex"}' +export CALLE_IDEMPOTENCY_KEY="" +uv run python examples/run_goal_and_wait.py + uv run python examples/webhook_server.py ``` -The webhook example listens on `POST /calle/webhook` and verifies -`CALL-E-Timestamp` plus `CALL-E-Signature` against the raw request body. +The webhook example listens on `POST /calle/webhook` and processes terminal +event JSON after post-call outcome and structured-result finalization. It +deduplicates deliveries with `CALL-E-Event-Id`; CALL-E does not send timestamp +or signature headers. -## Phase 1 scope +## Supported scope In scope: @@ -32,14 +40,19 @@ In scope: - Read a call. - Poll until a terminal call result. - List call events. -- Verify and unwrap signed webhook events. +- List and read published Goals. +- Create a Goal Run with a durable idempotency key. +- Poll until a Goal Run has either a result or an error. +- Receive finalized terminal webhook events without requiring signature + material. -Out of scope for Phase 1: +Out of scope: - Async client support. - Batch calls. - Cancel calls. - Recurring or scheduled calls. +- Goal authoring and publishing. - Project-level webhook management. - Pydantic result schema helpers. @@ -50,14 +63,24 @@ The SDK is generated and wrapped from `openapi/calle.openapi.yaml`. When the OpenAPI contract changes: 1. Update `openapi/calle.openapi.yaml`. -2. Regenerate generated client code if the generated package is in use. +2. Regenerate generated client code: + + ```bash + uv run openapi-python-client generate \ + --path openapi/calle.openapi.yaml \ + --config openapi-python-client.yml \ + --output-path src/calle/generated \ + --meta none \ + --overwrite + ``` + 3. Update wrappers and tests for any changed behavior. 4. Run the full development check list above. ## Pull requests Keep changes small and focused. Include tests for wrapper behavior, error -handling, webhook signature verification, and any changed API contract surface. +handling, webhook event handling, and any changed API contract surface. Do not add browser examples or patterns that expose CALL-E API keys to client code. diff --git a/README.md b/README.md index 87bcc11..fa80bbc 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ pip install calle-ai Pin the current stable release when your deployment process requires exact package reproducibility: ```bash -pip install calle-ai==0.2.0 +pip install calle-ai==0.6.0 ``` Use a local checkout for development and package smoke tests: @@ -49,18 +49,82 @@ Run the create-and-wait example from a local checkout: uv run python examples/create_and_wait.py ``` +Run a published Goal with an explicit Goal, phone, variables, and durable +idempotency key: + +```bash +export CALLE_GOAL_ID="" +export CALLE_GOAL_PHONE="" +export CALLE_GOAL_VARIABLES='{"name":"Alex"}' +export CALLE_IDEMPOTENCY_KEY="" +uv run python examples/run_goal_and_wait.py +``` + +To test against the test environment, explicitly set: + +```bash +export CALLE_BASE_URL="https://test-api.heycall-e.com" +``` + Run the webhook receiver example: ```bash -export CALLE_WEBHOOK_SECRET="whsec_test_key" uv run python examples/webhook_server.py ``` -The webhook receiver listens on `POST /calle/webhook` and verifies -`CALL-E-Timestamp` and `CALL-E-Signature` against the raw request body. +The webhook receiver listens on `POST /calle/webhook` and processes terminal +event JSON. CALL-E sends terminal events only after the post-call outcome and +requested structured results are finalized. + +CALL-E webhook delivery does not use a webhook secret, `CALL-E-Timestamp`, or +`CALL-E-Signature`. Use the required `CALL-E-Event-Id` header to deduplicate +at-least-once deliveries before performing side effects. The receiver example +parses JSON directly and checks that this header matches the body event id. + +The `client.webhooks.verify` and `client.webhooks.unwrap` methods implement the +legacy signed-payload contract from SDK `0.2`. They remain available for source +compatibility but are deprecated and are not compatible with current unsigned +CALL-E deliveries. ## Quickstart +Run a reusable published Goal. The Goal owns its input and result schemas; +each Run supplies only a phone number, per-Run variables, and a durable +idempotency key: + +```python +import os +from calle import CalleClient + +client = CalleClient(api_key=os.environ["CALLE_API_KEY"]) + +goal = client.goals.get("goal_delivery_confirmation") +print(goal["title"], goal["published_run_spec"]["input_schema"]) + +run = client.goals.run_and_wait( + goal_id=goal["id"], + phone="+14155550100", + variables={ + "customer_name": "Taylor", + "order_reference": "ORD-8472", + "delivery_window": "July 24, 2:00-4:00 PM", + }, + idempotency_key="delivery:ORD-8472:confirm-window:v1", +) + +if run["result"] is not None: + print(run["result"]) +else: + print(run["error"]) +``` + +Persist the idempotency key before the first request and reuse it for network +retries. `wait_for_result` returns when either `result` or `error` is non-null; +an execution `status` of `completed` can still be waiting for result +materialization. + +The generic one-shot call API remains available independently: + ```python import os from calle import CalleClient @@ -96,16 +160,6 @@ print(call["task_completed"], call["completion_confidence"], call["evidence"]) print(call["recipients"][0]["structured_result"]) ``` -## Webhook Verification - -```python -event = client.webhooks.unwrap( - raw_body=raw_body, - headers=headers, - secret=os.environ["CALLE_WEBHOOK_SECRET"], -) -``` - ## Release This repository publishes the Python distribution `calle-ai`. Application code @@ -128,11 +182,12 @@ Manual stable PyPI publish: ```bash python -m venv .venv . .venv/bin/activate -pip install calle-ai==0.2.0 -python -c 'from calle import CalleClient; print(CalleClient)' +pip install calle-ai==0.6.0 +python -c 'from calle import CalleClient; c = CalleClient(api_key="smoke"); assert callable(c.goals.run_and_wait); c.close()' ``` -The current stable version is `0.2.0`. Do not reuse a previously published PyPI version. +The current stable version is `0.6.0`. Do not reuse a previously published +PyPI version. ## Project Documents diff --git a/RELEASE.md b/RELEASE.md index 4475bab..bc247ba 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,7 +13,7 @@ calle-ai==0.1.0b1 The current production PyPI release version is: ```text -calle-ai==0.2.0 +calle-ai==0.6.0 ``` For this release, use token-based PyPI publishing with the GitHub Actions secret `PYPI_API_TOKEN`. @@ -26,7 +26,29 @@ Run these checks before publishing: bash scripts/validate.sh ``` -The validation script checks the OpenAPI contract, tests, lint, types, examples, distribution metadata, wheel install, source distribution install, and imports `CalleClient` from fresh virtual environments. +The validation script checks the OpenAPI contract, tests, lint, types, +examples, distribution metadata, wheel install, source distribution install, +and Goal wrapper plus generated-model imports from fresh virtual environments. + +## Test API Goal smoke + +Before publishing a release that changes Goal behavior, run the local release +candidate against a published Goal in the test environment: + +```bash +export CALLE_API_KEY="" +export CALLE_BASE_URL="https://test-api.heycall-e.com" +export CALLE_GOAL_ID="" +export CALLE_GOAL_PHONE="" +export CALLE_GOAL_VARIABLES='{"name":"Alex"}' +export CALLE_IDEMPOTENCY_KEY="" +uv run python examples/run_goal_and_wait.py +``` + +This smoke test creates a real phone call. Use an authorized test number and a +new idempotency key for a new logical test. Reuse the same key only when +retrying that exact request. Record the returned Goal Run id and verify that +exactly one of `result` or `error` is non-null. ## Stable PyPI publish @@ -45,15 +67,15 @@ tmpdir="$(mktemp -d)" python -m venv "$tmpdir/.venv" . "$tmpdir/.venv/bin/activate" python -m pip install --upgrade pip -python -m pip install calle-ai==0.2.0 -python -c 'from calle import CalleClient; print(CalleClient)' +python -m pip install calle-ai==0.6.0 +python -c 'from calle import CalleClient; c = CalleClient(api_key="smoke"); assert callable(c.goals.run_and_wait); c.close()' ``` ## Version rules - Patch releases fix SDK wrapper bugs, type issues, packaging metadata, README examples, or distribution issues without changing public API behavior. - Minor releases add backward-compatible API fields, endpoints, or SDK helpers. -- Major releases make breaking public API, method signature, stable error, or webhook signature contract changes. +- Major releases make breaking public API, method signature, stable error, or webhook delivery contract changes. Keep TypeScript, Python, OpenAPI, and public docs versions aligned by default. A single-language patch is allowed only when the shared API contract and cross-language behavior do not change. diff --git a/SECURITY.md b/SECURITY.md index bbaa610..d31fcfd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,8 +21,17 @@ Send a private report to the CALL-E maintainers with: ## Secret handling This SDK is for trusted server environments only. Do not expose CALL-E API keys -or webhook secrets in browser code, mobile apps, public logs, or client-side -bundles. +in browser code, mobile apps, public logs, or client-side bundles. -Webhook handlers must verify `CALL-E-Timestamp` and `CALL-E-Signature` against -the raw request body before parsing or trusting an event. +## Webhook receivers + +CALL-E terminal webhooks do not include a webhook secret, +`CALL-E-Timestamp`, or `CALL-E-Signature`. Do not treat the event id or payload +as cryptographic proof of origin. + +Treat the receiver as a public, untrusted-input boundary: accept only the +intended route, validate the JSON event shape, compare `CALL-E-Event-Id` with +the body event id, and persist that id before side effects so retries are +idempotent. If an integration requires origin assurance before a sensitive +action, fetch the referenced call through the authenticated Calls API and +compare its terminal snapshot. diff --git a/examples/run_goal_and_wait.py b/examples/run_goal_and_wait.py new file mode 100644 index 0000000..b76fe64 --- /dev/null +++ b/examples/run_goal_and_wait.py @@ -0,0 +1,69 @@ +import json +import math +import os +from typing import Any, NoReturn + +from calle import CalleClient + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if value is None or not value.strip(): + raise RuntimeError(f"Set {name} before running this example.") + return value + + +def reject_non_finite_constant(value: str) -> NoReturn: + raise ValueError(f"{value} is not valid JSON.") + + +def goal_variables() -> dict[str, str | int | float | bool]: + raw_variables = os.environ.get("CALLE_GOAL_VARIABLES", "{}") + try: + parsed: Any = json.loads( + raw_variables, + parse_constant=reject_non_finite_constant, + ) + except (json.JSONDecodeError, ValueError) as exc: + raise RuntimeError("CALLE_GOAL_VARIABLES must be valid finite JSON.") from exc + if not isinstance(parsed, dict): + raise RuntimeError("CALLE_GOAL_VARIABLES must be a JSON object.") + if not all( + isinstance(key, str) + and isinstance(value, (str, int, float, bool)) + and (not isinstance(value, float) or math.isfinite(value)) + for key, value in parsed.items() + ): + raise RuntimeError("CALLE_GOAL_VARIABLES values must be finite JSON scalars.") + return parsed + + +def exit_on_goal_error(run: dict[str, Any]) -> None: + if run["error"] is not None: + raise SystemExit(1) + + +def main() -> None: + goal_id = required_env("CALLE_GOAL_ID") + with CalleClient( + api_key=required_env("CALLE_API_KEY"), + base_url=os.environ.get("CALLE_BASE_URL", "https://api.heycall-e.com"), + ) as client: + goal = client.goals.get(goal_id) + print(json.dumps(goal, indent=2, ensure_ascii=False)) + + run = client.goals.run_and_wait( + goal_id=goal_id, + phone=required_env("CALLE_GOAL_PHONE"), + variables=goal_variables(), + idempotency_key=required_env("CALLE_IDEMPOTENCY_KEY"), + interval_seconds=float(os.environ.get("CALLE_POLL_INTERVAL_SECONDS", "2")), + timeout_seconds=float(os.environ.get("CALLE_POLL_TIMEOUT_SECONDS", "600")), + ) + + print(json.dumps(run, indent=2, ensure_ascii=False)) + exit_on_goal_error(run) + + +if __name__ == "__main__": + main() diff --git a/examples/webhook_server.py b/examples/webhook_server.py index 9e95544..bad5e7d 100644 --- a/examples/webhook_server.py +++ b/examples/webhook_server.py @@ -3,12 +3,8 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any -from calle import CalleClient, CalleWebhookSignatureError - - -client = CalleClient(api_key=os.environ.get("CALLE_API_KEY", "calle_dev_example")) -webhook_secret = os.environ.get("CALLE_WEBHOOK_SECRET", "whsec_dev_example") port = int(os.environ.get("PORT", "3000")) +processed_event_ids: set[str] = set() class WebhookHandler(BaseHTTPRequestHandler): @@ -20,21 +16,47 @@ def do_POST(self) -> None: raw_body = self.rfile.read(int(self.headers.get("content-length", "0"))) try: - event = client.webhooks.unwrap( - raw_body=raw_body, - headers=dict(self.headers.items()), - secret=webhook_secret, - ) - except CalleWebhookSignatureError: - self._send_json(400, {"error": "invalid_signature"}) + parsed: Any = json.loads(raw_body) + except (json.JSONDecodeError, UnicodeDecodeError): + self._send_json(400, {"error": "invalid_json"}) + return + if not isinstance(parsed, dict): + self._send_json(400, {"error": "invalid_event"}) + return + event: dict[str, Any] = parsed + + event_id = self.headers.get("CALL-E-Event-Id") + if not event_id or event.get("id") != event_id: + self._send_json(400, {"error": "invalid_event_id"}) + return + event_type = event.get("type") + call = event.get("data") + if ( + not isinstance(event_type, str) + or not isinstance(call, dict) + or not isinstance(call.get("id"), str) + ): + self._send_json(400, {"error": "invalid_event"}) return + call_id = call["id"] + + if event_id in processed_event_ids: + self._send_json(200, {"received": True, "duplicate": True}) + return + + # Use durable storage in production and persist the id before side effects. + processed_event_ids.add(event_id) - if event["type"] == "call.completed": + if event_type == "call.completed": print( "Call completed", { - "call_id": event["data"]["id"], - "result": event["data"].get("structured_result"), + "call_id": call_id, + "result": call.get("structured_result"), + "summary": call.get("summary"), + "task_completed": call.get("task_completed"), + "completion_confidence": call.get("completion_confidence"), + "evidence": call.get("evidence"), }, ) else: @@ -42,8 +64,8 @@ def do_POST(self) -> None: "CALL-E webhook event", { "id": event["id"], - "type": event["type"], - "call_id": event["data"]["id"], + "type": event_type, + "call_id": call_id, }, ) diff --git a/openapi-python-client.yml b/openapi-python-client.yml index 59db28a..5a09d76 100644 --- a/openapi-python-client.yml +++ b/openapi-python-client.yml @@ -1,6 +1,6 @@ project_name_override: calle-generated package_name_override: generated -package_version_override: 0.2.0 +package_version_override: 0.6.0 literal_enums: true generate_all_tags: true use_path_prefixes_for_title_model_names: false diff --git a/openapi/calle.openapi.yaml b/openapi/calle.openapi.yaml index f41fef9..269e273 100644 --- a/openapi/calle.openapi.yaml +++ b/openapi/calle.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: CALL-E Developer API - version: 0.2.0 + version: 0.6.0 description: Developer API contract used by the CALL-E TypeScript and Python SDKs. servers: - url: https://api.heycall-e.com @@ -15,7 +15,7 @@ paths: tags: - calls summary: Create Call - description: Create an asynchronous call. + description: Create an asynchronous call. Use `result_schema` and `recipient_result_schema` to ask CALL-E to extract structured JSON results from terminal call evidence. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: @@ -45,6 +45,8 @@ paths: properties: completed_count: type: integer + description: Number of recipients who clearly confirmed they can attend. + additionalProperties: false recipient_result_schema: type: object required: @@ -56,9 +58,52 @@ paths: - "yes" - "no" - unknown + description: Use yes when the recipient clearly confirms attendance. Use no when they clearly decline. Use unknown if the call did not reach the recipient or the answer is unclear. + additionalProperties: false metadata: workflow_run_id: wf_123 webhook_url: https://example.com/calle/webhook + salesHandoff: + summary: Route an interested prospect to a human. + value: + task: Call , explain our product briefly, and find out whether the prospect wants human assistance or shows strong buying interest. + result_schema: + type: object + required: + - human_assistance_requested + - interest_level + - handoff_recommended + - evidence_summary + properties: + human_assistance_requested: + type: string + enum: + - "yes" + - "no" + - unknown + description: Whether the prospect explicitly asked to speak with a human, sales representative, specialist, manager, or requested a callback from a person. Use unknown if the evidence is unclear. + interest_level: + type: string + enum: + - strong + - moderate + - low + - not_interested + - unknown + description: Use strong when the prospect asks about pricing, demos, next steps, availability, implementation, purchase process, or clearly wants follow-up. Use moderate for curiosity without a concrete next step. Use low for minimal engagement. Use not_interested when they clearly decline. Use unknown when evidence is insufficient. + handoff_recommended: + type: string + enum: + - "yes" + - "no" + - unknown + description: Use yes if human_assistance_requested is yes or interest_level is strong. Use no when the prospect is low interest or not interested. Use unknown when the evidence is insufficient. + evidence_summary: + type: string + description: One concise sentence citing the prospect's words or behavior that supports the handoff decision. + additionalProperties: false + metadata: + workflow_run_id: wf_sales_123 taskOnly: summary: Let CALL-E infer recipients from the task. value: @@ -268,13 +313,339 @@ paths: $ref: "#/components/responses/ErrorResponse" "500": $ref: "#/components/responses/ErrorResponse" + /v1/goals: + get: + operationId: listGoals + tags: + - goals + summary: List Goals + description: |- + List the authenticated owner's active, listed Goals that have a published RunSpec. For + example, a fulfillment service can inspect the published interface for its reusable + delivery-confirmation workflow before creating phone-specific Runs. + + Results are ordered by opaque Goal identity. Use `next_cursor` as the next request's + `after` value; clients must not parse or construct cursor values. `title` and `description` + help operators recognize each published workflow, but integrations should still store the + intended `goal_id` at publish time and must not execute the first list item blindly. + parameters: + - $ref: "#/components/parameters/GoalListLimit" + - $ref: "#/components/parameters/GoalListAfter" + responses: + "200": + description: Page of executable published Goal interfaces. + headers: + Cache-Control: + description: Prevent storage of owner-scoped Goal data. + schema: + type: string + enum: + - no-store + content: + application/json: + schema: + $ref: "#/components/schemas/GoalList" + examples: + deliveryConfirmation: + summary: Published delivery-window confirmation Goal. + value: + object: list + data: + - object: goal + id: goal_delivery_confirmation + title: Delivery window confirmation + description: Call a customer to confirm the proposed delivery window or collect a preferred alternative. + status: active + published_run_spec: + id: rspec_delivery_v4 + version: 4 + input_schema: + type: object + additionalProperties: false + properties: + customer_name: + type: string + description: Name the voice agent may use when greeting the customer. + order_reference: + type: string + description: Customer-safe order reference to mention during the call. + delivery_window: + type: string + description: Proposed local delivery date and time window. + required: + - customer_name + - order_reference + - delivery_window + result_schema: + type: object + additionalProperties: false + properties: + delivery_outcome: + type: string + enum: + - confirmed + - reschedule_requested + - declined + - unknown + preferred_window: + type: string + description: Alternative window requested by the customer, when stated. + required: + - delivery_outcome + next_cursor: null + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "403": + $ref: "#/components/responses/ErrorResponse" + "409": + $ref: "#/components/responses/ErrorResponse" + "429": + $ref: "#/components/responses/ErrorResponse" + "500": + $ref: "#/components/responses/ErrorResponse" + /v1/goals/{goal_id}: + get: + operationId: getGoal + tags: + - goals + summary: Get Goal + description: |- + Get an owner-scoped active Goal and its currently published immutable RunSpec interface. + + Store the `goal_id` returned by Chat publish success. `title` and `description` explain the + current published workflow. Before sending a delivery-confirmation Run, use `input_schema` + to verify that `customer_name`, `order_reference`, and `delivery_window` match the current + published version, and use `result_schema` to prepare downstream outcome handling. + + This endpoint does not search by title, objective, or recency, and does not expose authoring + instructions, provider bindings, or result materialization guidance. The + server always resolves the current published pointer for a new business key. + parameters: + - $ref: "#/components/parameters/GoalId" + responses: + "200": + description: Active Goal and its currently published RunSpec interface. + headers: + Cache-Control: + description: Prevent storage of owner-scoped Goal data. + schema: + type: string + enum: + - no-store + content: + application/json: + schema: + $ref: "#/components/schemas/Goal" + "401": + $ref: "#/components/responses/ErrorResponse" + "403": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "409": + $ref: "#/components/responses/ErrorResponse" + "429": + $ref: "#/components/responses/ErrorResponse" + "500": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + "503": + $ref: "#/components/responses/ErrorResponse" + /v1/goals/{goal_id}/runs: + post: + operationId: createGoalRun + tags: + - goal-runs + summary: Create Goal Run + description: |- + Create one singleton Goal Run for a published Goal. + + In a delivery-confirmation integration, `phone` identifies one customer and `variables` + provide that order's reference and proposed window. CALL-E atomically resolves and pins the + published RunSpec, validates the variables, and durably accepts execution. + + The request cannot select, replace, or relax schemas or the materialization contract. The + first accepted request and an exact idempotent replay both return `201` with the same Goal + Run identity. A `201` response means durable acceptance, not that the recipient answered or + that `result` is ready. + parameters: + - $ref: "#/components/parameters/GoalId" + - $ref: "#/components/parameters/GoalRunIdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateGoalRunRequest" + examples: + confirmDeliveryWindow: + summary: Ask one customer to confirm a proposed delivery window. + value: + phone: "" + variables: + customer_name: Taylor + order_reference: ORD-8472 + delivery_window: July 24, 2:00-4:00 PM + responses: + "201": + description: Goal Run durably accepted, or the current projection of an exact idempotent replay. + headers: + Cache-Control: + description: Prevent storage of owner-scoped Goal Run data. + schema: + type: string + enum: + - no-store + Location: + description: Relative URL of the Goal Run resource. + schema: + type: string + Retry-After: + description: Suggested number of seconds before polling the execution. + schema: + type: integer + minimum: 0 + content: + application/json: + schema: + $ref: "#/components/schemas/GoalRun" + examples: + acceptedDeliveryConfirmation: + summary: Delivery confirmation accepted and awaiting execution. + value: + object: goal_run + id: rgrp_delivery_ord_8472 + goal_id: goal_delivery_confirmation + run_id: run_delivery_ord_8472 + status: queued + run_spec: + id: rspec_delivery_v4 + version: 4 + result: null + error: null + created_at: "2026-07-22T10:00:00Z" + completed_at: null + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "402": + $ref: "#/components/responses/ErrorResponse" + "403": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "409": + $ref: "#/components/responses/ErrorResponse" + "422": + $ref: "#/components/responses/ErrorResponse" + "429": + $ref: "#/components/responses/ErrorResponse" + "500": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + "503": + $ref: "#/components/responses/ErrorResponse" + /v1/goals/{goal_id}/runs/{goal_run_id}: + get: + operationId: getGoalRun + tags: + - goal-runs + summary: Get Goal Run + description: |- + Get an owner- and Goal-scoped Run and its structured result facts. + + This is a pure read of the immutable execution snapshot. It does not resolve the current + Goal pointer, dispatch work, or start result materialization. Use the `GoalRun.id` returned + by create as `goal_run_id`; the nested telephone `run_id` is not valid in this path. + + Poll until either `result` or `error` is non-null. A non-null `result` is the parsed object + validated against the published result schema. A non-null `error` means this Run will not + produce a result. `status: completed` with both fields null means result processing is still + in progress. + parameters: + - $ref: "#/components/parameters/GoalId" + - $ref: "#/components/parameters/GoalRunId" + responses: + "200": + description: Current Goal Run execution and result state. + headers: + Cache-Control: + description: Prevent storage of owner-scoped Goal Run data. + schema: + type: string + enum: + - no-store + Retry-After: + description: Suggested number of seconds before polling again when results are pending. + schema: + type: integer + minimum: 0 + content: + application/json: + schema: + $ref: "#/components/schemas/GoalRun" + examples: + deliveryRescheduleRequested: + summary: Customer requested a different delivery window. + value: + object: goal_run + id: rgrp_delivery_ord_8472 + goal_id: goal_delivery_confirmation + run_id: run_delivery_ord_8472 + status: completed + run_spec: + id: rspec_delivery_v4 + version: 4 + result: + delivery_outcome: reschedule_requested + preferred_window: July 24 after 5:00 PM + error: null + created_at: "2026-07-22T10:00:00Z" + completed_at: "2026-07-22T10:01:12Z" + deliveryConfirmationNoAnswer: + summary: No human answered, so no business result is available. + value: + object: goal_run + id: rgrp_delivery_ord_8472 + goal_id: goal_delivery_confirmation + run_id: run_delivery_ord_8472 + status: failed + run_spec: + id: rspec_delivery_v4 + version: 4 + result: null + error: + code: no_answer + message: No human answered the call. + detail_code: no_human_answered + created_at: "2026-07-22T10:00:00Z" + completed_at: "2026-07-22T10:01:12Z" + "401": + $ref: "#/components/responses/ErrorResponse" + "403": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "429": + $ref: "#/components/responses/ErrorResponse" + "500": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + "503": + $ref: "#/components/responses/ErrorResponse" /calle/webhook: post: operationId: receiveWebhookEvent tags: - webhooks summary: Server Message - description: CALL-E sends this request to your server when a call reaches a terminal state. Configure this URL with `webhook_url` on create call or through project-level webhook settings. + description: CALL-E sends this request after a call reaches a terminal state and its post-call outcome and requested structured results are finalized. Configure this URL with `webhook_url` on create call or through project-level webhook settings. security: [] servers: - url: https://{yourserver} @@ -285,8 +656,6 @@ paths: description: Hostname for your webhook receiver. parameters: - $ref: "#/components/parameters/WebhookEventId" - - $ref: "#/components/parameters/WebhookTimestamp" - - $ref: "#/components/parameters/WebhookSignature" requestBody: required: true content: @@ -360,7 +729,7 @@ paths: value: ok: true "400": - description: Webhook rejected because the payload or signature was invalid. + description: Webhook rejected because the payload was invalid. components: securitySchemes: bearerAuth: @@ -376,6 +745,72 @@ components: type: string minLength: 1 maxLength: 255 + GoalRunIdempotencyKey: + name: Idempotency-Key + in: header + description: |- + Required business-stable identity for one logical Goal Run, scoped to the authenticated + owner and `goal_id`. Derive it from a durable workflow event, for example + `delivery:ORD-8472:confirm-window:v1`, and persist it before sending the request. + + Retry a timeout with the same key and byte-equivalent logical input. An exact replay returns + the original Goal Run with `201`; changing the phone or variables while reusing the key + returns `409 idempotency_conflict`. Do not generate a new random key for each network retry. + required: true + schema: + type: string + minLength: 1 + maxLength: 255 + example: delivery:ORD-8472:confirm-window:v1 + GoalListLimit: + name: limit + in: query + description: |- + Maximum number of Goal interfaces in this page. Defaults to `20`; values below `1` or above + `100` return `400 invalid_request`. A page can contain fewer items even when the limit is + larger. Continue only when `next_cursor` is non-null. + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + example: 20 + GoalListAfter: + name: after + in: query + description: |- + Opaque cursor returned as `next_cursor` by the immediately preceding list response. Pass it + unchanged together with the desired `limit`. Do not decode it, construct it from a Goal id, + or persist assumptions about its format. Omit this parameter for the first page. + required: false + schema: + type: string + minLength: 1 + example: goalcur_Z29hbF9kZWxpdmVyeV9jb25maXJtYXRpb24 + GoalId: + name: goal_id + in: path + description: |- + Opaque public Goal identity returned by CALL-E Chat publish success or `GET /v1/goals`. + Store it with your integration configuration. It identifies the reusable Goal and is + different from `published_run_spec.id`, `goal_run_id`, and the nested telephone `run_id`. + required: true + schema: + type: string + minLength: 1 + example: goal_delivery_confirmation + GoalRunId: + name: goal_run_id + in: path + description: |- + Opaque `GoalRun.id` returned by `POST /v1/goals/{goal_id}/runs`. Use it together with the + same `goal_id` when polling. Do not substitute the nested telephone `run_id`. + required: true + schema: + type: string + minLength: 1 + example: rgrp_delivery_ord_8472 CallId: name: call_id in: path @@ -393,30 +828,288 @@ components: type: string pattern: "^evt_[A-Za-z0-9_-]+$" example: evt_123 - WebhookTimestamp: - name: CALL-E-Timestamp - in: header - description: Unix timestamp used in the signed payload. Verify this header together with `CALL-E-Signature` before parsing JSON. - required: true - schema: - type: string - example: "1780309260" - WebhookSignature: - name: CALL-E-Signature - in: header - description: HMAC SHA-256 signature in the form `v1=`, computed over `timestamp + "." + raw_body` with your webhook secret. - required: true - schema: - type: string - example: v1=4d967f8f2b85f9c7d7f7ad1b9f8c5e3a5f1c2d3e4f5061728394050607080910 responses: ErrorResponse: description: Stable API error. + headers: + Cache-Control: + description: Prevent storage of owner-scoped API error details. + schema: + type: string + enum: + - no-store content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" schemas: + GoalList: + type: object + description: |- + Cursor-paginated collection of the authenticated owner's listed, active, published Goal + interfaces. Use this for discovery or recovery of a known Goal id, not as title search. + additionalProperties: false + required: + - object + - data + - next_cursor + properties: + object: + type: string + description: Always `list` for paginated list responses. + enum: + - list + data: + type: array + description: |- + Goal interfaces in stable opaque-id order. Do not assume the first item is the Goal your + workflow should execute; store the intended `goal_id` when it is published. + items: + $ref: "#/components/schemas/Goal" + next_cursor: + type: + - string + - "null" + description: Opaque cursor for the next page. `null` means there are no more Goals. + Goal: + type: object + description: |- + Owner-scoped active Goal and its currently published immutable RunSpec interface. This is an + execution contract, not an authoring record: it includes a developer-facing title and + description but omits prompts, provider settings, and history. + additionalProperties: false + required: + - object + - id + - title + - description + - status + - published_run_spec + properties: + object: + type: string + description: Always `goal` for Goal responses. + enum: + - goal + id: + type: string + description: Opaque Goal identity. This is distinct from the nested RunSpec identity. + minLength: 1 + title: + type: + - string + - "null" + description: Short developer-facing title from the current published RunSpec. It may be null for an untitled Goal. + minLength: 1 + description: + type: string + description: Developer-facing summary of what the current published Goal does. This is not the execution prompt. + minLength: 1 + status: + type: string + description: Always `active`; non-executable Goals are not returned by this surface. + enum: + - active + published_run_spec: + description: |- + Currently published immutable interface used to validate new `variables` and interpret + `result`. New business keys pin this version at acceptance time. + $ref: "#/components/schemas/GoalPublishedRunSpec" + GoalPublishedRunSpec: + type: object + description: |- + Read-only published RunSpec interface for a Goal. Applications should inspect the schemas + before constructing variables and should record the version used by deployments. + additionalProperties: false + required: + - id + - version + - input_schema + - result_schema + properties: + id: + type: string + description: Opaque immutable RunSpec identity. + minLength: 1 + version: + type: integer + description: Monotonic published version within this Goal. A later publish affects only new Runs. + minimum: 1 + input_schema: + type: object + description: |- + Normalized JSON Schema for per-Run `variables`. Respect `required`, property types, enum + values, defaults, and `additionalProperties`; invalid input is rejected before execution. + additionalProperties: true + result_schema: + type: object + description: |- + Normalized JSON Schema for `result`. CALL-E exposes a result only after it is + validated against this exact pinned schema and durably persisted. + additionalProperties: true + CreateGoalRunRequest: + type: object + description: |- + One phone-specific submission against the Goal's currently published RunSpec. The object is + closed: target wrappers, per-Run region/locale/display-name hints, task text, schemas, RunSpec + selectors, provider settings, and unknown fields are not accepted. Region, callee locale, and + runtime profile come from the published Goal. Use the required `Idempotency-Key` header for + retry safety. + additionalProperties: false + required: + - phone + properties: + phone: + type: string + description: |- + Recipient phone in canonical E.164 form: `+`, country code, and subscriber number with + no spaces, punctuation, or extension. The caller must be authorized to contact it. + CALL-E validates it against the published Goal's fixed Voice Target policy. + pattern: "^\\+[1-9]\\d{7,14}$" + variables: + description: |- + Per-Run business context validated against the pinned published `input_schema`. Keys and + required fields vary by Goal. Values must be finite JSON strings, numbers, or booleans; + nested objects, arrays, and null are not supported. Omit for Goals whose schema accepts `{}`. + default: {} + $ref: "#/components/schemas/GoalVariables" + GoalScalar: + type: + - string + - number + - boolean + description: Scalar value supported by published Goal input and result Schema profiles. + GoalVariables: + type: object + description: |- + Dynamic variable map validated by the exact published input schema pinned during acceptance. + Read the Goal interface rather than hard-coding undocumented keys. + additionalProperties: + $ref: "#/components/schemas/GoalScalar" + GoalRun: + type: object + description: |- + Public projection of one phone-specific execution of a published Goal. A non-null `result` + is a successfully parsed and persisted object. A non-null `error` means the Run will not + produce a result. When both are null, continue polling. + additionalProperties: false + required: + - object + - id + - goal_id + - run_id + - run_spec + - status + - result + - error + - created_at + - completed_at + properties: + object: + type: string + enum: + - goal_run + id: + type: string + description: Public Goal Run identity. Persist this value and use it as `goal_run_id` when polling. + minLength: 1 + goal_id: + type: string + description: Goal identity supplied in the create path. + minLength: 1 + run_id: + type: string + description: Internal execution member exposed for correlation; do not use it in the Goal Run polling path. + minLength: 1 + run_spec: + description: Read-only identity and version of the exact RunSpec pinned by this Run. + $ref: "#/components/schemas/GoalRunSpecSnapshot" + status: + $ref: "#/components/schemas/GoalRunStatus" + result: + type: + - object + - "null" + description: |- + Parsed result validated against the published result schema and durably persisted, or + `null` while processing or when the Run has an error. Its keys vary by Goal. + additionalProperties: + $ref: "#/components/schemas/GoalScalar" + error: + description: |- + Unified execution or result-processing error, or `null`. Branch on `code`; keep `message` + for logs and operators. A non-null error is final and is mutually exclusive with `result`. + oneOf: + - $ref: "#/components/schemas/GoalRunError" + - type: "null" + created_at: + type: string + format: date-time + description: UTC time at which CALL-E durably accepted this Goal Run. + completed_at: + type: + - string + - "null" + format: date-time + description: UTC telephone-execution completion time, or `null` while execution is non-terminal. + GoalRunSpecSnapshot: + type: object + description: Exact immutable RunSpec identity and version pinned by a Goal Run. + additionalProperties: false + required: + - id + - version + properties: + id: + type: string + minLength: 1 + description: Exact immutable RunSpec id pinned when the Goal Run was accepted. + version: + type: integer + minimum: 1 + description: Published RunSpec version pinned for this Goal Run. + GoalRunStatus: + type: string + description: |- + Stable telephone execution state. `queued` and `in_progress` are non-terminal; `completed`, + `failed`, and `canceled` are terminal. A completed call can still have `result: null` and + `error: null` briefly while CALL-E parses and saves the result. + enum: + - queued + - in_progress + - completed + - failed + - canceled + GoalRunError: + type: object + description: Unified safe error returned when a Goal Run cannot produce a usable result. + additionalProperties: false + required: + - code + - message + - detail_code + properties: + code: + type: string + enum: + - call_failed + - no_answer + - declined + - timed_out + - canceled + - result_invalid + - result_unavailable + - result_failed + message: + type: string + description: Human-readable safe explanation. Do not parse this field for application logic. + minLength: 1 + detail_code: + type: + - string + - "null" + description: Optional low-cardinality diagnostic detail safe for logs or narrow application branching. + maxLength: 128 CreateCallRequest: type: object additionalProperties: false @@ -439,13 +1132,31 @@ components: type: - object - "null" - description: Optional JSON Schema object that defines the structured result CALL-E should extract for the whole call task. Object schemas are strict by default; fields not declared in `properties` are rejected. + description: |- + Optional JSON Schema object that defines the structured result CALL-E should extract for the whole call task. + + CALL-E passes the schema, including field `description` values, to the extraction model after the call reaches a terminal state. Use descriptions to explain field meaning and enum selection logic, for example: "Use strong when the prospect asks about pricing, demos, or next steps." + + Descriptions guide extraction but are not hard validation rules. Hard validation comes from `type`, `required`, `enum`, and `additionalProperties`. + + Supported schema features are `type`, `properties`, `required`, `enum`, nested `object` fields, simple `array.items`, `description`, and `additionalProperties: false`. Unsupported features include `$ref`, `oneOf`, `anyOf`, `allOf`, recursive schemas, complex format validation, and `additionalProperties: true`. + + Prefer string enums over booleans for business decisions that may be unclear, and include an `unknown` enum value when the call may not provide enough evidence. additionalProperties: true recipient_result_schema: type: - object - "null" - description: Optional JSON Schema object that defines the structured result CALL-E should extract for each recipient. Object schemas are strict by default; fields not declared in `properties` are rejected. + description: |- + Optional JSON Schema object that defines the structured result CALL-E should extract independently for each recipient. + + This is useful for batch calls where each recipient needs their own outcome, such as `can_attend`, `confirmed`, `requested_callback`, or `interest_level`. + + Do not use reserved recipient response field names such as `summary`, `status`, `transcript`, `call_id`, or timing fields as custom result fields. Use names such as `customer_summary`, `notes`, or `reason` instead. + + Field `description` values are passed to the extraction model and should explain how enum values should be selected. Descriptions guide extraction but are not hard validation rules. Hard validation comes from `type`, `required`, `enum`, and `additionalProperties`. + + Object schemas are strict by default. Fields not declared in `properties` are rejected, and unsupported or invalid recipient results are returned as `null`. additionalProperties: true metadata: type: object @@ -480,7 +1191,7 @@ components: description: Recipient country or region code used for routing and compliance checks, for example `US`. CallStatus: type: string - description: Current lifecycle state of a CALL-E call. + description: Current lifecycle state of a CALL-E call. `in_progress` includes post-call result finalization; terminal states are published only after the post-call outcome is available. enum: - queued - in_progress @@ -647,7 +1358,10 @@ components: type: - object - "null" - description: Schema-valid structured result object extracted for this recipient. `null` when no usable structured result object was produced. + description: |- + Schema-valid structured result object extracted for this recipient using `recipient_result_schema`. + + `null` means CALL-E could not produce a schema-valid result for this recipient from the terminal call evidence, or no `recipient_result_schema` was provided. additionalProperties: true summary: type: @@ -702,7 +1416,10 @@ components: type: - object - "null" - description: Schema-valid structured result object extracted for the whole call task. `null` when no usable structured result object was produced. + description: |- + Schema-valid structured result object extracted for the whole call task using `result_schema`. + + `null` means CALL-E could not produce a schema-valid task-level result from the terminal call evidence, or no `result_schema` was provided. Check recipient-level `structured_result` when you use `recipient_result_schema` for batch calls. additionalProperties: true summary: type: @@ -746,7 +1463,7 @@ components: type: - string - "null" - description: ISO 8601 timestamp when the call reached a terminal state. `null` while queued or in progress. + description: ISO 8601 timestamp when the complete terminal call result was published. `null` while queued or in progress. format: date-time DeveloperEvent: type: object @@ -845,7 +1562,7 @@ components: description: Terminal call task snapshot associated with the webhook event. $ref: "#/components/schemas/WebhookCallData" WebhookCallData: - description: Terminal call task state included in webhook events. This shape is the same stable `call_task` object returned by the calls API. + description: Complete terminal call task state included in webhook events. This shape is the same stable `call_task` object returned by the calls API after post-call outcome and requested structured-result finalization. allOf: - $ref: "#/components/schemas/CallTask" WebhookAcknowledgement: @@ -891,6 +1608,11 @@ components: - result_schema_invalid - recipient_result_schema_invalid - idempotency_conflict + - goal_not_published + - goal_not_executable + - goal_not_ready + - schema_override_not_allowed + - variables_invalid - provider_unavailable - internal_error - not_found diff --git a/pyproject.toml b/pyproject.toml index c5923f2..7a9d350 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "calle-ai" -version = "0.2.0" +version = "0.6.0" description = "Python server SDK for the CALL-E Developer API." readme = "README.md" requires-python = ">=3.11" @@ -15,6 +15,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "attrs>=22.2.0", "httpx>=0.27.0,<1.0.0", ] diff --git a/scripts/smoke_install_dist.sh b/scripts/smoke_install_dist.sh index 4c85857..3988b8b 100755 --- a/scripts/smoke_install_dist.sh +++ b/scripts/smoke_install_dist.sh @@ -18,4 +18,12 @@ fi . "$smoke_dir/.venv/bin/activate" python -m pip install --upgrade pip python -m pip install "$artifact" -python -c 'from calle import CalleClient; print(CalleClient)' +python - <<'PY' +from calle import CalleClient +from calle.generated.models import Goal, GoalRun + +client = CalleClient(api_key="smoke") +assert callable(client.goals.run_and_wait) +client.close() +print(CalleClient, Goal, GoalRun) +PY diff --git a/scripts/validate.sh b/scripts/validate.sh index e7db1ed..1e19863 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -7,7 +7,10 @@ uv run python scripts/verify_openapi_contract.py uv run pytest -q uv run ruff check . uv run mypy src/calle -uv run python -m py_compile examples/create_and_wait.py examples/webhook_server.py +uv run python -m py_compile \ + examples/create_and_wait.py \ + examples/run_goal_and_wait.py \ + examples/webhook_server.py uv build uvx twine check dist/* bash scripts/smoke_install_dist.sh dist/*.whl diff --git a/scripts/verify_openapi_contract.py b/scripts/verify_openapi_contract.py index 0a85531..36bb82b 100644 --- a/scripts/verify_openapi_contract.py +++ b/scripts/verify_openapi_contract.py @@ -49,7 +49,7 @@ def main() -> None: "unexpected title", ) assert_contract( - spec.get("info", {}).get("version") == "0.2.0", + spec.get("info", {}).get("version") == "0.6.0", "unexpected API version", ) @@ -77,6 +77,48 @@ def main() -> None: "response_schema": "#/components/schemas/EventList", "error_statuses": ["401", "403", "404", "429", "500"], }, + { + "path": "/v1/goals", + "method": "get", + "operation_id": "listGoals", + "response_schema": "#/components/schemas/GoalList", + "error_statuses": ["400", "401", "403", "409", "429", "500"], + }, + { + "path": "/v1/goals/{goal_id}", + "method": "get", + "operation_id": "getGoal", + "response_schema": "#/components/schemas/Goal", + "error_statuses": ["401", "403", "404", "409", "429", "500", "502", "503"], + }, + { + "path": "/v1/goals/{goal_id}/runs", + "method": "post", + "operation_id": "createGoalRun", + "request_schema": "#/components/schemas/CreateGoalRunRequest", + "response_status": "201", + "response_schema": "#/components/schemas/GoalRun", + "error_statuses": [ + "400", + "401", + "402", + "403", + "404", + "409", + "422", + "429", + "500", + "502", + "503", + ], + }, + { + "path": "/v1/goals/{goal_id}/runs/{goal_run_id}", + "method": "get", + "operation_id": "getGoalRun", + "response_schema": "#/components/schemas/GoalRun", + "error_statuses": ["401", "403", "404", "429", "500", "502", "503"], + }, { "path": "/calle/webhook", "method": "post", @@ -118,12 +160,15 @@ def main() -> None: ) webhook_refs = parameter_refs(spec, "/calle/webhook", "post") - for ref in [ - "#/components/parameters/WebhookEventId", - "#/components/parameters/WebhookTimestamp", - "#/components/parameters/WebhookSignature", - ]: - assert_contract(ref in webhook_refs, f"missing webhook parameter {ref}") + assert_contract( + webhook_refs == ["#/components/parameters/WebhookEventId"], + "webhook endpoint must expose only the event id header", + ) + parameters = spec.get("components", {}).get("parameters", {}) + assert_contract( + "WebhookTimestamp" not in parameters and "WebhookSignature" not in parameters, + "webhook contract must not define legacy signature parameters", + ) webhook_security = spec.get("paths", {}).get("/calle/webhook", {}).get("post", {}).get( "security" @@ -149,11 +194,38 @@ def main() -> None: "WebhookEvent", "WebhookCallData", "WebhookAcknowledgement", + "GoalList", + "Goal", + "GoalPublishedRunSpec", + "CreateGoalRunRequest", + "GoalVariables", + "GoalRun", + "GoalRunSpecSnapshot", + "GoalRunStatus", + "GoalRunError", "ErrorEnvelope", "APIError", ]: assert_contract(schema_name in schemas, f"missing schema {schema_name}") + assert_contract( + schemas["WebhookCallData"].get("allOf") + == [{"$ref": "#/components/schemas/CallTask"}], + "webhook data must reuse the complete call task shape", + ) + terminal_result_fields = { + "structured_result", + "summary", + "task_completed", + "completion_confidence", + "evidence", + "completed_at", + } + assert_contract( + terminal_result_fields.issubset(set(schemas["CallTask"].get("required", []))), + "complete terminal result fields must remain required in webhook call data", + ) + create_call_properties = schemas["CreateCallRequest"].get("properties", {}) for property_name in [ "task", @@ -205,6 +277,70 @@ def main() -> None: "EventList.data must contain DeveloperEvent items", ) + create_goal_run = schemas["CreateGoalRunRequest"] + create_goal_run_properties = create_goal_run.get("properties", {}) + assert_contract( + sorted(create_goal_run_properties) == ["phone", "variables"], + "CreateGoalRunRequest must contain only phone and variables", + ) + assert_contract( + create_goal_run.get("required") == ["phone"], + "CreateGoalRunRequest must require only phone", + ) + assert_contract( + create_goal_run.get("additionalProperties") is False, + "CreateGoalRunRequest must reject unknown fields", + ) + + goal_properties = schemas["Goal"].get("properties", {}) + for property_name in [ + "id", + "title", + "description", + "status", + "published_run_spec", + ]: + assert_contract(property_name in goal_properties, f"Goal missing {property_name}") + + published_run_spec_properties = schemas["GoalPublishedRunSpec"].get("properties", {}) + for checksum in [ + "semantic_checksum", + "input_schema_checksum", + "result_schema_checksum", + ]: + assert_contract(checksum not in goal_properties, f"Goal must not expose {checksum}") + assert_contract( + checksum not in published_run_spec_properties, + f"GoalPublishedRunSpec must not expose {checksum}", + ) + + goal_run_properties = schemas["GoalRun"].get("properties", {}) + for property_name in [ + "id", + "goal_id", + "run_id", + "run_spec", + "status", + "result", + "error", + "created_at", + "completed_at", + ]: + assert_contract( + property_name in goal_run_properties, + f"GoalRun missing {property_name}", + ) + + goal_run_parameter_refs = parameter_refs( + spec, + "/v1/goals/{goal_id}/runs", + "post", + ) + assert_contract( + "#/components/parameters/GoalRunIdempotencyKey" in goal_run_parameter_refs, + "create Goal Run must require the stable idempotency header", + ) + print(f"Verified CALL-E OpenAPI contract at {SPEC_PATH}.") diff --git a/src/calle/client.py b/src/calle/client.py index 5bcd57a..4422b75 100644 --- a/src/calle/client.py +++ b/src/calle/client.py @@ -1,6 +1,7 @@ import httpx from calle.calls import CalleCalls +from calle.goals import CalleGoals from calle.webhooks import CalleWebhooks @@ -20,6 +21,7 @@ def __init__( timeout=timeout, ) self.calls = CalleCalls(client=self._client) + self.goals = CalleGoals(client=self._client) self.webhooks = CalleWebhooks() def close(self) -> None: diff --git a/src/calle/errors.py b/src/calle/errors.py index ee7e77d..14bfb1f 100644 --- a/src/calle/errors.py +++ b/src/calle/errors.py @@ -33,6 +33,8 @@ class CalleConnectionError(Exception): class CalleWebhookSignatureError(Exception): + """Legacy signed-webhook validation error retained for SDK 0.2 compatibility.""" + pass diff --git a/src/calle/generated/api/calls/create_call.py b/src/calle/generated/api/calls/create_call.py index e22fe15..5fbead5 100644 --- a/src/calle/generated/api/calls/create_call.py +++ b/src/calle/generated/api/calls/create_call.py @@ -3,11 +3,13 @@ import httpx -from ... import errors from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + from ...models.create_call_request import CreateCallRequest from ...models.error_envelope import ErrorEnvelope -from ...types import UNSET, Response, Unset +from ...types import Unset def _get_kwargs( @@ -32,7 +34,9 @@ def _get_kwargs( return _kwargs -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> ErrorEnvelope | None: +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorEnvelope | None: if response.status_code == 400: response_400 = ErrorEnvelope.from_dict(response.json()) @@ -74,7 +78,9 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return None -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[ErrorEnvelope]: +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorEnvelope]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -91,7 +97,8 @@ def sync_detailed( ) -> Response[ErrorEnvelope]: """Create Call - Create an asynchronous call. + Create an asynchronous call. Use `result_schema` and `recipient_result_schema` to ask CALL-E to + extract structured JSON results from terminal call evidence. Args: idempotency_key (str | Unset): @@ -125,7 +132,8 @@ def sync( ) -> ErrorEnvelope | None: """Create Call - Create an asynchronous call. + Create an asynchronous call. Use `result_schema` and `recipient_result_schema` to ask CALL-E to + extract structured JSON results from terminal call evidence. Args: idempotency_key (str | Unset): @@ -154,7 +162,8 @@ async def asyncio_detailed( ) -> Response[ErrorEnvelope]: """Create Call - Create an asynchronous call. + Create an asynchronous call. Use `result_schema` and `recipient_result_schema` to ask CALL-E to + extract structured JSON results from terminal call evidence. Args: idempotency_key (str | Unset): @@ -186,7 +195,8 @@ async def asyncio( ) -> ErrorEnvelope | None: """Create Call - Create an asynchronous call. + Create an asynchronous call. Use `result_schema` and `recipient_result_schema` to ask CALL-E to + extract structured JSON results from terminal call evidence. Args: idempotency_key (str | Unset): diff --git a/src/calle/generated/api/calls/get_call.py b/src/calle/generated/api/calls/get_call.py index 1f7a6f3..24b2f78 100644 --- a/src/calle/generated/api/calls/get_call.py +++ b/src/calle/generated/api/calls/get_call.py @@ -4,10 +4,11 @@ import httpx -from ... import errors from ...client import AuthenticatedClient, Client -from ...models.error_envelope import ErrorEnvelope from ...types import Response +from ... import errors + +from ...models.error_envelope import ErrorEnvelope def _get_kwargs( @@ -24,7 +25,9 @@ def _get_kwargs( return _kwargs -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> ErrorEnvelope | None: +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorEnvelope | None: if response.status_code == 401: response_401 = ErrorEnvelope.from_dict(response.json()) @@ -56,7 +59,9 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return None -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[ErrorEnvelope]: +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorEnvelope]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, diff --git a/src/calle/generated/api/calls/list_call_events.py b/src/calle/generated/api/calls/list_call_events.py index acf983d..8b7393e 100644 --- a/src/calle/generated/api/calls/list_call_events.py +++ b/src/calle/generated/api/calls/list_call_events.py @@ -4,11 +4,13 @@ import httpx -from ... import errors from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + from ...models.error_envelope import ErrorEnvelope from ...models.event_list import EventList -from ...types import UNSET, Response, Unset +from ...types import Unset def _get_kwargs( diff --git a/src/calle/generated/api/goal_runs/__init__.py b/src/calle/generated/api/goal_runs/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/src/calle/generated/api/goal_runs/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/src/calle/generated/api/goal_runs/create_goal_run.py b/src/calle/generated/api/goal_runs/create_goal_run.py new file mode 100644 index 0000000..f680ee5 --- /dev/null +++ b/src/calle/generated/api/goal_runs/create_goal_run.py @@ -0,0 +1,323 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response +from ... import errors + +from ...models.create_goal_run_request import CreateGoalRunRequest +from ...models.error_envelope import ErrorEnvelope +from ...models.goal_run import GoalRun + + +def _get_kwargs( + goal_id: str, + *, + body: CreateGoalRunRequest, + idempotency_key: str, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + headers["Idempotency-Key"] = idempotency_key + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v1/goals/{goal_id}/runs".format( + goal_id=quote(str(goal_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorEnvelope | GoalRun | None: + if response.status_code == 201: + response_201 = GoalRun.from_dict(response.json()) + + return response_201 + + if response.status_code == 400: + response_400 = ErrorEnvelope.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorEnvelope.from_dict(response.json()) + + return response_401 + + if response.status_code == 402: + response_402 = ErrorEnvelope.from_dict(response.json()) + + return response_402 + + if response.status_code == 403: + response_403 = ErrorEnvelope.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorEnvelope.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ErrorEnvelope.from_dict(response.json()) + + return response_409 + + if response.status_code == 422: + response_422 = ErrorEnvelope.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorEnvelope.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorEnvelope.from_dict(response.json()) + + return response_500 + + if response.status_code == 502: + response_502 = ErrorEnvelope.from_dict(response.json()) + + return response_502 + + if response.status_code == 503: + response_503 = ErrorEnvelope.from_dict(response.json()) + + return response_503 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorEnvelope | GoalRun]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + goal_id: str, + *, + client: AuthenticatedClient | Client, + body: CreateGoalRunRequest, + idempotency_key: str, +) -> Response[ErrorEnvelope | GoalRun]: + """Create Goal Run + + Create one singleton Goal Run for a published Goal. + + In a delivery-confirmation integration, `phone` identifies one customer and `variables` + provide that order's reference and proposed window. CALL-E atomically resolves and pins the + published RunSpec, validates the variables, and durably accepts execution. + + The request cannot select, replace, or relax schemas or the materialization contract. The + first accepted request and an exact idempotent replay both return `201` with the same Goal + Run identity. A `201` response means durable acceptance, not that the recipient answered or + that `result` is ready. + + Args: + goal_id (str): + idempotency_key (str): + body (CreateGoalRunRequest): One phone-specific submission against the Goal's currently + published RunSpec. The object is + closed: target wrappers, per-Run region/locale/display-name hints, task text, schemas, + RunSpec + selectors, provider settings, and unknown fields are not accepted. Region, callee locale, + and + runtime profile come from the published Goal. Use the required `Idempotency-Key` header + for + retry safety. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | GoalRun] + """ + + kwargs = _get_kwargs( + goal_id=goal_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + goal_id: str, + *, + client: AuthenticatedClient | Client, + body: CreateGoalRunRequest, + idempotency_key: str, +) -> ErrorEnvelope | GoalRun | None: + """Create Goal Run + + Create one singleton Goal Run for a published Goal. + + In a delivery-confirmation integration, `phone` identifies one customer and `variables` + provide that order's reference and proposed window. CALL-E atomically resolves and pins the + published RunSpec, validates the variables, and durably accepts execution. + + The request cannot select, replace, or relax schemas or the materialization contract. The + first accepted request and an exact idempotent replay both return `201` with the same Goal + Run identity. A `201` response means durable acceptance, not that the recipient answered or + that `result` is ready. + + Args: + goal_id (str): + idempotency_key (str): + body (CreateGoalRunRequest): One phone-specific submission against the Goal's currently + published RunSpec. The object is + closed: target wrappers, per-Run region/locale/display-name hints, task text, schemas, + RunSpec + selectors, provider settings, and unknown fields are not accepted. Region, callee locale, + and + runtime profile come from the published Goal. Use the required `Idempotency-Key` header + for + retry safety. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | GoalRun + """ + + return sync_detailed( + goal_id=goal_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ).parsed + + +async def asyncio_detailed( + goal_id: str, + *, + client: AuthenticatedClient | Client, + body: CreateGoalRunRequest, + idempotency_key: str, +) -> Response[ErrorEnvelope | GoalRun]: + """Create Goal Run + + Create one singleton Goal Run for a published Goal. + + In a delivery-confirmation integration, `phone` identifies one customer and `variables` + provide that order's reference and proposed window. CALL-E atomically resolves and pins the + published RunSpec, validates the variables, and durably accepts execution. + + The request cannot select, replace, or relax schemas or the materialization contract. The + first accepted request and an exact idempotent replay both return `201` with the same Goal + Run identity. A `201` response means durable acceptance, not that the recipient answered or + that `result` is ready. + + Args: + goal_id (str): + idempotency_key (str): + body (CreateGoalRunRequest): One phone-specific submission against the Goal's currently + published RunSpec. The object is + closed: target wrappers, per-Run region/locale/display-name hints, task text, schemas, + RunSpec + selectors, provider settings, and unknown fields are not accepted. Region, callee locale, + and + runtime profile come from the published Goal. Use the required `Idempotency-Key` header + for + retry safety. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | GoalRun] + """ + + kwargs = _get_kwargs( + goal_id=goal_id, + body=body, + idempotency_key=idempotency_key, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + goal_id: str, + *, + client: AuthenticatedClient | Client, + body: CreateGoalRunRequest, + idempotency_key: str, +) -> ErrorEnvelope | GoalRun | None: + """Create Goal Run + + Create one singleton Goal Run for a published Goal. + + In a delivery-confirmation integration, `phone` identifies one customer and `variables` + provide that order's reference and proposed window. CALL-E atomically resolves and pins the + published RunSpec, validates the variables, and durably accepts execution. + + The request cannot select, replace, or relax schemas or the materialization contract. The + first accepted request and an exact idempotent replay both return `201` with the same Goal + Run identity. A `201` response means durable acceptance, not that the recipient answered or + that `result` is ready. + + Args: + goal_id (str): + idempotency_key (str): + body (CreateGoalRunRequest): One phone-specific submission against the Goal's currently + published RunSpec. The object is + closed: target wrappers, per-Run region/locale/display-name hints, task text, schemas, + RunSpec + selectors, provider settings, and unknown fields are not accepted. Region, callee locale, + and + runtime profile come from the published Goal. Use the required `Idempotency-Key` header + for + retry safety. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | GoalRun + """ + + return ( + await asyncio_detailed( + goal_id=goal_id, + client=client, + body=body, + idempotency_key=idempotency_key, + ) + ).parsed diff --git a/src/calle/generated/api/goal_runs/get_goal_run.py b/src/calle/generated/api/goal_runs/get_goal_run.py new file mode 100644 index 0000000..6c31ca6 --- /dev/null +++ b/src/calle/generated/api/goal_runs/get_goal_run.py @@ -0,0 +1,250 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response +from ... import errors + +from ...models.error_envelope import ErrorEnvelope +from ...models.goal_run import GoalRun + + +def _get_kwargs( + goal_id: str, + goal_run_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v1/goals/{goal_id}/runs/{goal_run_id}".format( + goal_id=quote(str(goal_id), safe=""), + goal_run_id=quote(str(goal_run_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorEnvelope | GoalRun | None: + if response.status_code == 200: + response_200 = GoalRun.from_dict(response.json()) + + return response_200 + + if response.status_code == 401: + response_401 = ErrorEnvelope.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorEnvelope.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorEnvelope.from_dict(response.json()) + + return response_404 + + if response.status_code == 429: + response_429 = ErrorEnvelope.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorEnvelope.from_dict(response.json()) + + return response_500 + + if response.status_code == 502: + response_502 = ErrorEnvelope.from_dict(response.json()) + + return response_502 + + if response.status_code == 503: + response_503 = ErrorEnvelope.from_dict(response.json()) + + return response_503 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorEnvelope | GoalRun]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + goal_id: str, + goal_run_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ErrorEnvelope | GoalRun]: + """Get Goal Run + + Get an owner- and Goal-scoped Run and its structured result facts. + + This is a pure read of the immutable execution snapshot. It does not resolve the current + Goal pointer, dispatch work, or start result materialization. Use the `GoalRun.id` returned + by create as `goal_run_id`; the nested telephone `run_id` is not valid in this path. + + Poll until either `result` or `error` is non-null. A non-null `result` is the parsed object + validated against the published result schema. A non-null `error` means this Run will not + produce a result. `status: completed` with both fields null means result processing is still + in progress. + + Args: + goal_id (str): + goal_run_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | GoalRun] + """ + + kwargs = _get_kwargs( + goal_id=goal_id, + goal_run_id=goal_run_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + goal_id: str, + goal_run_id: str, + *, + client: AuthenticatedClient | Client, +) -> ErrorEnvelope | GoalRun | None: + """Get Goal Run + + Get an owner- and Goal-scoped Run and its structured result facts. + + This is a pure read of the immutable execution snapshot. It does not resolve the current + Goal pointer, dispatch work, or start result materialization. Use the `GoalRun.id` returned + by create as `goal_run_id`; the nested telephone `run_id` is not valid in this path. + + Poll until either `result` or `error` is non-null. A non-null `result` is the parsed object + validated against the published result schema. A non-null `error` means this Run will not + produce a result. `status: completed` with both fields null means result processing is still + in progress. + + Args: + goal_id (str): + goal_run_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | GoalRun + """ + + return sync_detailed( + goal_id=goal_id, + goal_run_id=goal_run_id, + client=client, + ).parsed + + +async def asyncio_detailed( + goal_id: str, + goal_run_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ErrorEnvelope | GoalRun]: + """Get Goal Run + + Get an owner- and Goal-scoped Run and its structured result facts. + + This is a pure read of the immutable execution snapshot. It does not resolve the current + Goal pointer, dispatch work, or start result materialization. Use the `GoalRun.id` returned + by create as `goal_run_id`; the nested telephone `run_id` is not valid in this path. + + Poll until either `result` or `error` is non-null. A non-null `result` is the parsed object + validated against the published result schema. A non-null `error` means this Run will not + produce a result. `status: completed` with both fields null means result processing is still + in progress. + + Args: + goal_id (str): + goal_run_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | GoalRun] + """ + + kwargs = _get_kwargs( + goal_id=goal_id, + goal_run_id=goal_run_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + goal_id: str, + goal_run_id: str, + *, + client: AuthenticatedClient | Client, +) -> ErrorEnvelope | GoalRun | None: + """Get Goal Run + + Get an owner- and Goal-scoped Run and its structured result facts. + + This is a pure read of the immutable execution snapshot. It does not resolve the current + Goal pointer, dispatch work, or start result materialization. Use the `GoalRun.id` returned + by create as `goal_run_id`; the nested telephone `run_id` is not valid in this path. + + Poll until either `result` or `error` is non-null. A non-null `result` is the parsed object + validated against the published result schema. A non-null `error` means this Run will not + produce a result. `status: completed` with both fields null means result processing is still + in progress. + + Args: + goal_id (str): + goal_run_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | GoalRun + """ + + return ( + await asyncio_detailed( + goal_id=goal_id, + goal_run_id=goal_run_id, + client=client, + ) + ).parsed diff --git a/src/calle/generated/api/goals/__init__.py b/src/calle/generated/api/goals/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/src/calle/generated/api/goals/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/src/calle/generated/api/goals/get_goal.py b/src/calle/generated/api/goals/get_goal.py new file mode 100644 index 0000000..6d5b4df --- /dev/null +++ b/src/calle/generated/api/goals/get_goal.py @@ -0,0 +1,241 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response +from ... import errors + +from ...models.error_envelope import ErrorEnvelope +from ...models.goal import Goal + + +def _get_kwargs( + goal_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v1/goals/{goal_id}".format( + goal_id=quote(str(goal_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorEnvelope | Goal | None: + if response.status_code == 200: + response_200 = Goal.from_dict(response.json()) + + return response_200 + + if response.status_code == 401: + response_401 = ErrorEnvelope.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorEnvelope.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorEnvelope.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ErrorEnvelope.from_dict(response.json()) + + return response_409 + + if response.status_code == 429: + response_429 = ErrorEnvelope.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorEnvelope.from_dict(response.json()) + + return response_500 + + if response.status_code == 502: + response_502 = ErrorEnvelope.from_dict(response.json()) + + return response_502 + + if response.status_code == 503: + response_503 = ErrorEnvelope.from_dict(response.json()) + + return response_503 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorEnvelope | Goal]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + goal_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ErrorEnvelope | Goal]: + """Get Goal + + Get an owner-scoped active Goal and its currently published immutable RunSpec interface. + + Store the `goal_id` returned by Chat publish success. `title` and `description` explain the + current published workflow. Before sending a delivery-confirmation Run, use `input_schema` + to verify that `customer_name`, `order_reference`, and `delivery_window` match the current + published version, and use `result_schema` to prepare downstream outcome handling. + + This endpoint does not search by title, objective, or recency, and does not expose authoring + instructions, provider bindings, or result materialization guidance. The + server always resolves the current published pointer for a new business key. + + Args: + goal_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | Goal] + """ + + kwargs = _get_kwargs( + goal_id=goal_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + goal_id: str, + *, + client: AuthenticatedClient | Client, +) -> ErrorEnvelope | Goal | None: + """Get Goal + + Get an owner-scoped active Goal and its currently published immutable RunSpec interface. + + Store the `goal_id` returned by Chat publish success. `title` and `description` explain the + current published workflow. Before sending a delivery-confirmation Run, use `input_schema` + to verify that `customer_name`, `order_reference`, and `delivery_window` match the current + published version, and use `result_schema` to prepare downstream outcome handling. + + This endpoint does not search by title, objective, or recency, and does not expose authoring + instructions, provider bindings, or result materialization guidance. The + server always resolves the current published pointer for a new business key. + + Args: + goal_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | Goal + """ + + return sync_detailed( + goal_id=goal_id, + client=client, + ).parsed + + +async def asyncio_detailed( + goal_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ErrorEnvelope | Goal]: + """Get Goal + + Get an owner-scoped active Goal and its currently published immutable RunSpec interface. + + Store the `goal_id` returned by Chat publish success. `title` and `description` explain the + current published workflow. Before sending a delivery-confirmation Run, use `input_schema` + to verify that `customer_name`, `order_reference`, and `delivery_window` match the current + published version, and use `result_schema` to prepare downstream outcome handling. + + This endpoint does not search by title, objective, or recency, and does not expose authoring + instructions, provider bindings, or result materialization guidance. The + server always resolves the current published pointer for a new business key. + + Args: + goal_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | Goal] + """ + + kwargs = _get_kwargs( + goal_id=goal_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + goal_id: str, + *, + client: AuthenticatedClient | Client, +) -> ErrorEnvelope | Goal | None: + """Get Goal + + Get an owner-scoped active Goal and its currently published immutable RunSpec interface. + + Store the `goal_id` returned by Chat publish success. `title` and `description` explain the + current published workflow. Before sending a delivery-confirmation Run, use `input_schema` + to verify that `customer_name`, `order_reference`, and `delivery_window` match the current + published version, and use `result_schema` to prepare downstream outcome handling. + + This endpoint does not search by title, objective, or recency, and does not expose authoring + instructions, provider bindings, or result materialization guidance. The + server always resolves the current published pointer for a new business key. + + Args: + goal_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | Goal + """ + + return ( + await asyncio_detailed( + goal_id=goal_id, + client=client, + ) + ).parsed diff --git a/src/calle/generated/api/goals/list_goals.py b/src/calle/generated/api/goals/list_goals.py new file mode 100644 index 0000000..8c6fd0a --- /dev/null +++ b/src/calle/generated/api/goals/list_goals.py @@ -0,0 +1,244 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.error_envelope import ErrorEnvelope +from ...models.goal_list import GoalList +from ...types import Unset + + +def _get_kwargs( + *, + limit: int | Unset = 20, + after: str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["limit"] = limit + + params["after"] = after + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v1/goals", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorEnvelope | GoalList | None: + if response.status_code == 200: + response_200 = GoalList.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorEnvelope.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorEnvelope.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorEnvelope.from_dict(response.json()) + + return response_403 + + if response.status_code == 409: + response_409 = ErrorEnvelope.from_dict(response.json()) + + return response_409 + + if response.status_code == 429: + response_429 = ErrorEnvelope.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorEnvelope.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorEnvelope | GoalList]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + limit: int | Unset = 20, + after: str | Unset = UNSET, +) -> Response[ErrorEnvelope | GoalList]: + """List Goals + + List the authenticated owner's active, listed Goals that have a published RunSpec. For + example, a fulfillment service can inspect the published interface for its reusable + delivery-confirmation workflow before creating phone-specific Runs. + + Results are ordered by opaque Goal identity. Use `next_cursor` as the next request's + `after` value; clients must not parse or construct cursor values. `title` and `description` + help operators recognize each published workflow, but integrations should still store the + intended `goal_id` at publish time and must not execute the first list item blindly. + + Args: + limit (int | Unset): Default: 20. + after (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | GoalList] + """ + + kwargs = _get_kwargs( + limit=limit, + after=after, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + limit: int | Unset = 20, + after: str | Unset = UNSET, +) -> ErrorEnvelope | GoalList | None: + """List Goals + + List the authenticated owner's active, listed Goals that have a published RunSpec. For + example, a fulfillment service can inspect the published interface for its reusable + delivery-confirmation workflow before creating phone-specific Runs. + + Results are ordered by opaque Goal identity. Use `next_cursor` as the next request's + `after` value; clients must not parse or construct cursor values. `title` and `description` + help operators recognize each published workflow, but integrations should still store the + intended `goal_id` at publish time and must not execute the first list item blindly. + + Args: + limit (int | Unset): Default: 20. + after (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | GoalList + """ + + return sync_detailed( + client=client, + limit=limit, + after=after, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + limit: int | Unset = 20, + after: str | Unset = UNSET, +) -> Response[ErrorEnvelope | GoalList]: + """List Goals + + List the authenticated owner's active, listed Goals that have a published RunSpec. For + example, a fulfillment service can inspect the published interface for its reusable + delivery-confirmation workflow before creating phone-specific Runs. + + Results are ordered by opaque Goal identity. Use `next_cursor` as the next request's + `after` value; clients must not parse or construct cursor values. `title` and `description` + help operators recognize each published workflow, but integrations should still store the + intended `goal_id` at publish time and must not execute the first list item blindly. + + Args: + limit (int | Unset): Default: 20. + after (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorEnvelope | GoalList] + """ + + kwargs = _get_kwargs( + limit=limit, + after=after, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + limit: int | Unset = 20, + after: str | Unset = UNSET, +) -> ErrorEnvelope | GoalList | None: + """List Goals + + List the authenticated owner's active, listed Goals that have a published RunSpec. For + example, a fulfillment service can inspect the published interface for its reusable + delivery-confirmation workflow before creating phone-specific Runs. + + Results are ordered by opaque Goal identity. Use `next_cursor` as the next request's + `after` value; clients must not parse or construct cursor values. `title` and `description` + help operators recognize each published workflow, but integrations should still store the + intended `goal_id` at publish time and must not execute the first list item blindly. + + Args: + limit (int | Unset): Default: 20. + after (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorEnvelope | GoalList + """ + + return ( + await asyncio_detailed( + client=client, + limit=limit, + after=after, + ) + ).parsed diff --git a/src/calle/generated/client.py b/src/calle/generated/client.py index 1b7055a..9d77029 100644 --- a/src/calle/generated/client.py +++ b/src/calle/generated/client.py @@ -1,8 +1,8 @@ import ssl from typing import Any +from attrs import define, field, evolve import httpx -from attrs import define, evolve, field @define @@ -39,8 +39,12 @@ class Client: _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") - _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") - _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _verify_ssl: str | bool | ssl.SSLContext = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") _client: httpx.Client | None = field(default=None, init=False) _async_client: httpx.AsyncClient | None = field(default=None, init=False) @@ -169,8 +173,12 @@ class AuthenticatedClient: _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") - _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") - _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _verify_ssl: str | bool | ssl.SSLContext = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") _client: httpx.Client | None = field(default=None, init=False) _async_client: httpx.AsyncClient | None = field(default=None, init=False) @@ -214,7 +222,9 @@ def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": def get_httpx_client(self) -> httpx.Client: """Get the underlying httpx.Client, constructing a new one if not previously set""" if self._client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) self._client = httpx.Client( base_url=self._base_url, cookies=self._cookies, @@ -235,7 +245,9 @@ def __exit__(self, *args: Any, **kwargs: Any) -> None: """Exit a context manager for internal httpx.Client (see httpx docs)""" self.get_httpx_client().__exit__(*args, **kwargs) - def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": + def set_async_httpx_client( + self, async_client: httpx.AsyncClient + ) -> "AuthenticatedClient": """Manually set the underlying httpx.AsyncClient **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. @@ -246,7 +258,9 @@ def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Authentica def get_async_httpx_client(self) -> httpx.AsyncClient: """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" if self._async_client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) self._async_client = httpx.AsyncClient( base_url=self._base_url, cookies=self._cookies, diff --git a/src/calle/generated/models/__init__.py b/src/calle/generated/models/__init__.py index 683b3d2..0676857 100644 --- a/src/calle/generated/models/__init__.py +++ b/src/calle/generated/models/__init__.py @@ -9,20 +9,41 @@ from .call_task_object import CallTaskObject from .call_task_recipient import CallTaskRecipient from .call_task_recipient_request import CallTaskRecipientRequest -from .call_task_recipient_structured_result_type_0 import CallTaskRecipientStructuredResultType0 +from .call_task_recipient_structured_result_type_0 import ( + CallTaskRecipientStructuredResultType0, +) from .call_task_structured_result_type_0 import CallTaskStructuredResultType0 from .call_transcript_turn import CallTranscriptTurn from .completion_confidence import CompletionConfidence from .create_call_request import CreateCallRequest from .create_call_request_metadata import CreateCallRequestMetadata -from .create_call_request_recipient_result_schema_type_0 import CreateCallRequestRecipientResultSchemaType0 +from .create_call_request_recipient_result_schema_type_0 import ( + CreateCallRequestRecipientResultSchemaType0, +) from .create_call_request_result_schema_type_0 import CreateCallRequestResultSchemaType0 +from .create_goal_run_request import CreateGoalRunRequest from .developer_event import DeveloperEvent from .developer_event_details import DeveloperEventDetails from .developer_event_level import DeveloperEventLevel from .error_envelope import ErrorEnvelope from .event_list import EventList from .event_list_object import EventListObject +from .goal import Goal +from .goal_list import GoalList +from .goal_list_object import GoalListObject +from .goal_object import GoalObject +from .goal_published_run_spec import GoalPublishedRunSpec +from .goal_published_run_spec_input_schema import GoalPublishedRunSpecInputSchema +from .goal_published_run_spec_result_schema import GoalPublishedRunSpecResultSchema +from .goal_run import GoalRun +from .goal_run_error import GoalRunError +from .goal_run_error_code import GoalRunErrorCode +from .goal_run_object import GoalRunObject +from .goal_run_result_type_0 import GoalRunResultType0 +from .goal_run_spec_snapshot import GoalRunSpecSnapshot +from .goal_run_status import GoalRunStatus +from .goal_status import GoalStatus +from .goal_variables import GoalVariables from .recipient_status import RecipientStatus from .transcript_speaker import TranscriptSpeaker from .webhook_acknowledgement import WebhookAcknowledgement @@ -46,12 +67,29 @@ "CreateCallRequestMetadata", "CreateCallRequestRecipientResultSchemaType0", "CreateCallRequestResultSchemaType0", + "CreateGoalRunRequest", "DeveloperEvent", "DeveloperEventDetails", "DeveloperEventLevel", "ErrorEnvelope", "EventList", "EventListObject", + "Goal", + "GoalList", + "GoalListObject", + "GoalObject", + "GoalPublishedRunSpec", + "GoalPublishedRunSpecInputSchema", + "GoalPublishedRunSpecResultSchema", + "GoalRun", + "GoalRunError", + "GoalRunErrorCode", + "GoalRunObject", + "GoalRunResultType0", + "GoalRunSpecSnapshot", + "GoalRunStatus", + "GoalStatus", + "GoalVariables", "RecipientStatus", "TranscriptSpeaker", "WebhookAcknowledgement", diff --git a/src/calle/generated/models/api_error.py b/src/calle/generated/models/api_error.py index 446fb99..bca5ddf 100644 --- a/src/calle/generated/models/api_error.py +++ b/src/calle/generated/models/api_error.py @@ -1,11 +1,13 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import Any, TypeVar, TYPE_CHECKING from attrs import define as _attrs_define -from ..models.api_error_code import APIErrorCode, check_api_error_code + +from ..models.api_error_code import APIErrorCode +from ..models.api_error_code import check_api_error_code if TYPE_CHECKING: from ..models.api_error_details import APIErrorDetails diff --git a/src/calle/generated/models/api_error_code.py b/src/calle/generated/models/api_error_code.py index bbe25b7..820655a 100644 --- a/src/calle/generated/models/api_error_code.py +++ b/src/calle/generated/models/api_error_code.py @@ -3,6 +3,9 @@ APIErrorCode = Literal[ "call_not_ready", "forbidden", + "goal_not_executable", + "goal_not_published", + "goal_not_ready", "idempotency_conflict", "insufficient_balance", "internal_error", @@ -17,14 +20,19 @@ "recipient_blocked", "recipient_result_schema_invalid", "result_schema_invalid", + "schema_override_not_allowed", "unauthorized", "unsupported_language", "unsupported_region", + "variables_invalid", ] API_ERROR_CODE_VALUES: set[APIErrorCode] = { "call_not_ready", "forbidden", + "goal_not_executable", + "goal_not_published", + "goal_not_ready", "idempotency_conflict", "insufficient_balance", "internal_error", @@ -39,13 +47,17 @@ "recipient_blocked", "recipient_result_schema_invalid", "result_schema_invalid", + "schema_override_not_allowed", "unauthorized", "unsupported_language", "unsupported_region", + "variables_invalid", } def check_api_error_code(value: str) -> APIErrorCode: if value in API_ERROR_CODE_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {API_ERROR_CODE_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {API_ERROR_CODE_VALUES!r}" + ) diff --git a/src/calle/generated/models/api_error_details.py b/src/calle/generated/models/api_error_details.py index 18fcc46..76fb7b2 100644 --- a/src/calle/generated/models/api_error_details.py +++ b/src/calle/generated/models/api_error_details.py @@ -6,6 +6,7 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field + T = TypeVar("T", bound="APIErrorDetails") diff --git a/src/calle/generated/models/attempt_status.py b/src/calle/generated/models/attempt_status.py index 875e92d..997ed56 100644 --- a/src/calle/generated/models/attempt_status.py +++ b/src/calle/generated/models/attempt_status.py @@ -1,6 +1,8 @@ from typing import Literal -AttemptStatus = Literal["canceled", "completed", "dialing", "failed", "in_progress", "queued"] +AttemptStatus = Literal[ + "canceled", "completed", "dialing", "failed", "in_progress", "queued" +] ATTEMPT_STATUS_VALUES: set[AttemptStatus] = { "canceled", @@ -15,4 +17,6 @@ def check_attempt_status(value: str) -> AttemptStatus: if value in ATTEMPT_STATUS_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {ATTEMPT_STATUS_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {ATTEMPT_STATUS_VALUES!r}" + ) diff --git a/src/calle/generated/models/call_status.py b/src/calle/generated/models/call_status.py index 8fe1b9f..515f4b1 100644 --- a/src/calle/generated/models/call_status.py +++ b/src/calle/generated/models/call_status.py @@ -14,4 +14,6 @@ def check_call_status(value: str) -> CallStatus: if value in CALL_STATUS_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {CALL_STATUS_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {CALL_STATUS_VALUES!r}" + ) diff --git a/src/calle/generated/models/call_task_attempt.py b/src/calle/generated/models/call_task_attempt.py index f368741..bd7746d 100644 --- a/src/calle/generated/models/call_task_attempt.py +++ b/src/calle/generated/models/call_task_attempt.py @@ -1,12 +1,15 @@ from __future__ import annotations -import datetime from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import Any, TypeVar, TYPE_CHECKING from attrs import define as _attrs_define -from ..models.attempt_status import AttemptStatus, check_attempt_status + +from ..models.attempt_status import AttemptStatus +from ..models.attempt_status import check_attempt_status +from typing import cast +import datetime if TYPE_CHECKING: from ..models.call_transcript_turn import CallTranscriptTurn @@ -152,7 +155,9 @@ def _parse_summary(data: object) -> None | str: transcript_turns = [] _transcript_turns = d.pop("transcript_turns") for transcript_turns_item_data in _transcript_turns: - transcript_turns_item = CallTranscriptTurn.from_dict(transcript_turns_item_data) + transcript_turns_item = CallTranscriptTurn.from_dict( + transcript_turns_item_data + ) transcript_turns.append(transcript_turns_item) diff --git a/src/calle/generated/models/call_task_object.py b/src/calle/generated/models/call_task_object.py index 7449ad7..7957753 100644 --- a/src/calle/generated/models/call_task_object.py +++ b/src/calle/generated/models/call_task_object.py @@ -10,4 +10,6 @@ def check_call_task_object(value: str) -> CallTaskObject: if value in CALL_TASK_OBJECT_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {CALL_TASK_OBJECT_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {CALL_TASK_OBJECT_VALUES!r}" + ) diff --git a/src/calle/generated/models/call_task_recipient.py b/src/calle/generated/models/call_task_recipient.py index f9cc48e..af67a73 100644 --- a/src/calle/generated/models/call_task_recipient.py +++ b/src/calle/generated/models/call_task_recipient.py @@ -1,15 +1,20 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import Any, TypeVar, TYPE_CHECKING from attrs import define as _attrs_define -from ..models.recipient_status import RecipientStatus, check_recipient_status + +from ..models.recipient_status import check_recipient_status +from ..models.recipient_status import RecipientStatus +from typing import cast if TYPE_CHECKING: from ..models.call_task_attempt import CallTaskAttempt - from ..models.call_task_recipient_structured_result_type_0 import CallTaskRecipientStructuredResultType0 + from ..models.call_task_recipient_structured_result_type_0 import ( + CallTaskRecipientStructuredResultType0, + ) T = TypeVar("T", bound="CallTaskRecipient") @@ -25,7 +30,10 @@ class CallTaskRecipient: region (None | str): Country or region code used for routing and compliance checks when available. status (RecipientStatus): Current lifecycle state for one recipient in a call task. structured_result (CallTaskRecipientStructuredResultType0 | None): Schema-valid structured result object - extracted for this recipient. `null` when no usable structured result object was produced. + extracted for this recipient using `recipient_result_schema`. + + `null` means CALL-E could not produce a schema-valid result for this recipient from the terminal call evidence, + or no `recipient_result_schema` was provided. summary (None | str): Short human-readable summary for this recipient. `null` while the recipient is still running or when no useful summary is available. attempts (list[CallTaskAttempt]): Outbound dial attempts made for this recipient. @@ -41,7 +49,9 @@ class CallTaskRecipient: attempts: list[CallTaskAttempt] def to_dict(self) -> dict[str, Any]: - from ..models.call_task_recipient_structured_result_type_0 import CallTaskRecipientStructuredResultType0 + from ..models.call_task_recipient_structured_result_type_0 import ( + CallTaskRecipientStructuredResultType0, + ) id = self.id @@ -89,7 +99,9 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.call_task_attempt import CallTaskAttempt - from ..models.call_task_recipient_structured_result_type_0 import CallTaskRecipientStructuredResultType0 + from ..models.call_task_recipient_structured_result_type_0 import ( + CallTaskRecipientStructuredResultType0, + ) d = dict(src_dict) id = d.pop("id") @@ -112,13 +124,17 @@ def _parse_region(data: object) -> None | str: status = check_recipient_status(d.pop("status")) - def _parse_structured_result(data: object) -> CallTaskRecipientStructuredResultType0 | None: + def _parse_structured_result( + data: object, + ) -> CallTaskRecipientStructuredResultType0 | None: if data is None: return data try: if not isinstance(data, dict): raise TypeError() - structured_result_type_0 = CallTaskRecipientStructuredResultType0.from_dict(data) + structured_result_type_0 = ( + CallTaskRecipientStructuredResultType0.from_dict(data) + ) return structured_result_type_0 except (TypeError, ValueError, AttributeError, KeyError): diff --git a/src/calle/generated/models/call_task_recipient_request.py b/src/calle/generated/models/call_task_recipient_request.py index 6bd5d95..fb3396f 100644 --- a/src/calle/generated/models/call_task_recipient_request.py +++ b/src/calle/generated/models/call_task_recipient_request.py @@ -1,12 +1,15 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, TypeVar from attrs import define as _attrs_define from ..types import UNSET, Unset +from typing import cast + + T = TypeVar("T", bound="CallTaskRecipientRequest") diff --git a/src/calle/generated/models/call_task_recipient_structured_result_type_0.py b/src/calle/generated/models/call_task_recipient_structured_result_type_0.py index 88b0407..014fab1 100644 --- a/src/calle/generated/models/call_task_recipient_structured_result_type_0.py +++ b/src/calle/generated/models/call_task_recipient_structured_result_type_0.py @@ -6,13 +6,16 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field + T = TypeVar("T", bound="CallTaskRecipientStructuredResultType0") @_attrs_define class CallTaskRecipientStructuredResultType0: - """Schema-valid structured result object extracted for this recipient. `null` when no usable structured result object - was produced. + """Schema-valid structured result object extracted for this recipient using `recipient_result_schema`. + + `null` means CALL-E could not produce a schema-valid result for this recipient from the terminal call evidence, or + no `recipient_result_schema` was provided. """ diff --git a/src/calle/generated/models/call_task_structured_result_type_0.py b/src/calle/generated/models/call_task_structured_result_type_0.py index 68c7d71..0b764bf 100644 --- a/src/calle/generated/models/call_task_structured_result_type_0.py +++ b/src/calle/generated/models/call_task_structured_result_type_0.py @@ -6,13 +6,17 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field + T = TypeVar("T", bound="CallTaskStructuredResultType0") @_attrs_define class CallTaskStructuredResultType0: - """Schema-valid structured result object extracted for the whole call task. `null` when no usable structured result - object was produced. + """Schema-valid structured result object extracted for the whole call task using `result_schema`. + + `null` means CALL-E could not produce a schema-valid task-level result from the terminal call evidence, or no + `result_schema` was provided. Check recipient-level `structured_result` when you use `recipient_result_schema` for + batch calls. """ diff --git a/src/calle/generated/models/call_transcript_turn.py b/src/calle/generated/models/call_transcript_turn.py index 172d1db..380a434 100644 --- a/src/calle/generated/models/call_transcript_turn.py +++ b/src/calle/generated/models/call_transcript_turn.py @@ -1,11 +1,15 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, TypeVar from attrs import define as _attrs_define -from ..models.transcript_speaker import TranscriptSpeaker, check_transcript_speaker + +from ..models.transcript_speaker import check_transcript_speaker +from ..models.transcript_speaker import TranscriptSpeaker +from typing import cast + T = TypeVar("T", bound="CallTranscriptTurn") diff --git a/src/calle/generated/models/completion_confidence.py b/src/calle/generated/models/completion_confidence.py index ec36453..dd13bec 100644 --- a/src/calle/generated/models/completion_confidence.py +++ b/src/calle/generated/models/completion_confidence.py @@ -5,6 +5,7 @@ from attrs import define as _attrs_define + T = TypeVar("T", bound="CompletionConfidence") diff --git a/src/calle/generated/models/create_call_request.py b/src/calle/generated/models/create_call_request.py index ee295f8..1287270 100644 --- a/src/calle/generated/models/create_call_request.py +++ b/src/calle/generated/models/create_call_request.py @@ -1,17 +1,23 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import Any, TypeVar, TYPE_CHECKING from attrs import define as _attrs_define from ..types import UNSET, Unset +from typing import cast + if TYPE_CHECKING: from ..models.call_task_recipient_request import CallTaskRecipientRequest from ..models.create_call_request_metadata import CreateCallRequestMetadata - from ..models.create_call_request_recipient_result_schema_type_0 import CreateCallRequestRecipientResultSchemaType0 - from ..models.create_call_request_result_schema_type_0 import CreateCallRequestResultSchemaType0 + from ..models.create_call_request_recipient_result_schema_type_0 import ( + CreateCallRequestRecipientResultSchemaType0, + ) + from ..models.create_call_request_result_schema_type_0 import ( + CreateCallRequestResultSchemaType0, + ) T = TypeVar("T", bound="CreateCallRequest") @@ -26,11 +32,36 @@ class CreateCallRequest: recipients (list[CallTaskRecipientRequest] | None | Unset): Optional explicit recipients for this call task. Omit it when the task text already contains the phone targets CALL-E should use. result_schema (CreateCallRequestResultSchemaType0 | None | Unset): Optional JSON Schema object that defines the - structured result CALL-E should extract for the whole call task. Object schemas are strict by default; fields - not declared in `properties` are rejected. + structured result CALL-E should extract for the whole call task. + + CALL-E passes the schema, including field `description` values, to the extraction model after the call reaches a + terminal state. Use descriptions to explain field meaning and enum selection logic, for example: "Use strong + when the prospect asks about pricing, demos, or next steps." + + Descriptions guide extraction but are not hard validation rules. Hard validation comes from `type`, `required`, + `enum`, and `additionalProperties`. + + Supported schema features are `type`, `properties`, `required`, `enum`, nested `object` fields, simple + `array.items`, `description`, and `additionalProperties: false`. Unsupported features include `$ref`, `oneOf`, + `anyOf`, `allOf`, recursive schemas, complex format validation, and `additionalProperties: true`. + + Prefer string enums over booleans for business decisions that may be unclear, and include an `unknown` enum + value when the call may not provide enough evidence. recipient_result_schema (CreateCallRequestRecipientResultSchemaType0 | None | Unset): Optional JSON Schema - object that defines the structured result CALL-E should extract for each recipient. Object schemas are strict by - default; fields not declared in `properties` are rejected. + object that defines the structured result CALL-E should extract independently for each recipient. + + This is useful for batch calls where each recipient needs their own outcome, such as `can_attend`, `confirmed`, + `requested_callback`, or `interest_level`. + + Do not use reserved recipient response field names such as `summary`, `status`, `transcript`, `call_id`, or + timing fields as custom result fields. Use names such as `customer_summary`, `notes`, or `reason` instead. + + Field `description` values are passed to the extraction model and should explain how enum values should be + selected. Descriptions guide extraction but are not hard validation rules. Hard validation comes from `type`, + `required`, `enum`, and `additionalProperties`. + + Object schemas are strict by default. Fields not declared in `properties` are rejected, and unsupported or + invalid recipient results are returned as `null`. metadata (CreateCallRequestMetadata | Unset): Optional caller-owned metadata echoed on the call and webhook payloads. Use this for workflow ids, tenant ids, or internal correlation keys. webhook_url (str | Unset): Optional per-request HTTPS webhook URL. When provided, CALL-E sends terminal call @@ -40,7 +71,9 @@ class CreateCallRequest: task: str recipients: list[CallTaskRecipientRequest] | None | Unset = UNSET result_schema: CreateCallRequestResultSchemaType0 | None | Unset = UNSET - recipient_result_schema: CreateCallRequestRecipientResultSchemaType0 | None | Unset = UNSET + recipient_result_schema: ( + CreateCallRequestRecipientResultSchemaType0 | None | Unset + ) = UNSET metadata: CreateCallRequestMetadata | Unset = UNSET webhook_url: str | Unset = UNSET @@ -48,7 +81,9 @@ def to_dict(self) -> dict[str, Any]: from ..models.create_call_request_recipient_result_schema_type_0 import ( CreateCallRequestRecipientResultSchemaType0, ) - from ..models.create_call_request_result_schema_type_0 import CreateCallRequestResultSchemaType0 + from ..models.create_call_request_result_schema_type_0 import ( + CreateCallRequestResultSchemaType0, + ) task = self.task @@ -75,7 +110,9 @@ def to_dict(self) -> dict[str, Any]: recipient_result_schema: dict[str, Any] | None | Unset if isinstance(self.recipient_result_schema, Unset): recipient_result_schema = UNSET - elif isinstance(self.recipient_result_schema, CreateCallRequestRecipientResultSchemaType0): + elif isinstance( + self.recipient_result_schema, CreateCallRequestRecipientResultSchemaType0 + ): recipient_result_schema = self.recipient_result_schema.to_dict() else: recipient_result_schema = self.recipient_result_schema @@ -113,12 +150,16 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.create_call_request_recipient_result_schema_type_0 import ( CreateCallRequestRecipientResultSchemaType0, ) - from ..models.create_call_request_result_schema_type_0 import CreateCallRequestResultSchemaType0 + from ..models.create_call_request_result_schema_type_0 import ( + CreateCallRequestResultSchemaType0, + ) d = dict(src_dict) task = d.pop("task") - def _parse_recipients(data: object) -> list[CallTaskRecipientRequest] | None | Unset: + def _parse_recipients( + data: object, + ) -> list[CallTaskRecipientRequest] | None | Unset: if data is None: return data if isinstance(data, Unset): @@ -129,7 +170,9 @@ def _parse_recipients(data: object) -> list[CallTaskRecipientRequest] | None | U recipients_type_0 = [] _recipients_type_0 = data for recipients_type_0_item_data in _recipients_type_0: - recipients_type_0_item = CallTaskRecipientRequest.from_dict(recipients_type_0_item_data) + recipients_type_0_item = CallTaskRecipientRequest.from_dict( + recipients_type_0_item_data + ) recipients_type_0.append(recipients_type_0_item) @@ -140,7 +183,9 @@ def _parse_recipients(data: object) -> list[CallTaskRecipientRequest] | None | U recipients = _parse_recipients(d.pop("recipients", UNSET)) - def _parse_result_schema(data: object) -> CreateCallRequestResultSchemaType0 | None | Unset: + def _parse_result_schema( + data: object, + ) -> CreateCallRequestResultSchemaType0 | None | Unset: if data is None: return data if isinstance(data, Unset): @@ -148,7 +193,9 @@ def _parse_result_schema(data: object) -> CreateCallRequestResultSchemaType0 | N try: if not isinstance(data, dict): raise TypeError() - result_schema_type_0 = CreateCallRequestResultSchemaType0.from_dict(data) + result_schema_type_0 = CreateCallRequestResultSchemaType0.from_dict( + data + ) return result_schema_type_0 except (TypeError, ValueError, AttributeError, KeyError): @@ -157,7 +204,9 @@ def _parse_result_schema(data: object) -> CreateCallRequestResultSchemaType0 | N result_schema = _parse_result_schema(d.pop("result_schema", UNSET)) - def _parse_recipient_result_schema(data: object) -> CreateCallRequestRecipientResultSchemaType0 | None | Unset: + def _parse_recipient_result_schema( + data: object, + ) -> CreateCallRequestRecipientResultSchemaType0 | None | Unset: if data is None: return data if isinstance(data, Unset): @@ -165,14 +214,20 @@ def _parse_recipient_result_schema(data: object) -> CreateCallRequestRecipientRe try: if not isinstance(data, dict): raise TypeError() - recipient_result_schema_type_0 = CreateCallRequestRecipientResultSchemaType0.from_dict(data) + recipient_result_schema_type_0 = ( + CreateCallRequestRecipientResultSchemaType0.from_dict(data) + ) return recipient_result_schema_type_0 except (TypeError, ValueError, AttributeError, KeyError): pass - return cast(CreateCallRequestRecipientResultSchemaType0 | None | Unset, data) + return cast( + CreateCallRequestRecipientResultSchemaType0 | None | Unset, data + ) - recipient_result_schema = _parse_recipient_result_schema(d.pop("recipient_result_schema", UNSET)) + recipient_result_schema = _parse_recipient_result_schema( + d.pop("recipient_result_schema", UNSET) + ) _metadata = d.pop("metadata", UNSET) metadata: CreateCallRequestMetadata | Unset diff --git a/src/calle/generated/models/create_call_request_metadata.py b/src/calle/generated/models/create_call_request_metadata.py index 1df9f61..95a51f9 100644 --- a/src/calle/generated/models/create_call_request_metadata.py +++ b/src/calle/generated/models/create_call_request_metadata.py @@ -6,6 +6,7 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field + T = TypeVar("T", bound="CreateCallRequestMetadata") diff --git a/src/calle/generated/models/create_call_request_recipient_result_schema_type_0.py b/src/calle/generated/models/create_call_request_recipient_result_schema_type_0.py index 7afe0f8..81e46f5 100644 --- a/src/calle/generated/models/create_call_request_recipient_result_schema_type_0.py +++ b/src/calle/generated/models/create_call_request_recipient_result_schema_type_0.py @@ -6,13 +6,27 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field + T = TypeVar("T", bound="CreateCallRequestRecipientResultSchemaType0") @_attrs_define class CreateCallRequestRecipientResultSchemaType0: - """Optional JSON Schema object that defines the structured result CALL-E should extract for each recipient. Object - schemas are strict by default; fields not declared in `properties` are rejected. + """Optional JSON Schema object that defines the structured result CALL-E should extract independently for each + recipient. + + This is useful for batch calls where each recipient needs their own outcome, such as `can_attend`, `confirmed`, + `requested_callback`, or `interest_level`. + + Do not use reserved recipient response field names such as `summary`, `status`, `transcript`, `call_id`, or timing + fields as custom result fields. Use names such as `customer_summary`, `notes`, or `reason` instead. + + Field `description` values are passed to the extraction model and should explain how enum values should be selected. + Descriptions guide extraction but are not hard validation rules. Hard validation comes from `type`, `required`, + `enum`, and `additionalProperties`. + + Object schemas are strict by default. Fields not declared in `properties` are rejected, and unsupported or invalid + recipient results are returned as `null`. """ diff --git a/src/calle/generated/models/create_call_request_result_schema_type_0.py b/src/calle/generated/models/create_call_request_result_schema_type_0.py index dfaa18f..bff754a 100644 --- a/src/calle/generated/models/create_call_request_result_schema_type_0.py +++ b/src/calle/generated/models/create_call_request_result_schema_type_0.py @@ -6,13 +6,27 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field + T = TypeVar("T", bound="CreateCallRequestResultSchemaType0") @_attrs_define class CreateCallRequestResultSchemaType0: - """Optional JSON Schema object that defines the structured result CALL-E should extract for the whole call task. Object - schemas are strict by default; fields not declared in `properties` are rejected. + """Optional JSON Schema object that defines the structured result CALL-E should extract for the whole call task. + + CALL-E passes the schema, including field `description` values, to the extraction model after the call reaches a + terminal state. Use descriptions to explain field meaning and enum selection logic, for example: "Use strong when + the prospect asks about pricing, demos, or next steps." + + Descriptions guide extraction but are not hard validation rules. Hard validation comes from `type`, `required`, + `enum`, and `additionalProperties`. + + Supported schema features are `type`, `properties`, `required`, `enum`, nested `object` fields, simple + `array.items`, `description`, and `additionalProperties: false`. Unsupported features include `$ref`, `oneOf`, + `anyOf`, `allOf`, recursive schemas, complex format validation, and `additionalProperties: true`. + + Prefer string enums over booleans for business decisions that may be unclear, and include an `unknown` enum value + when the call may not provide enough evidence. """ diff --git a/src/calle/generated/models/create_goal_run_request.py b/src/calle/generated/models/create_goal_run_request.py new file mode 100644 index 0000000..1741328 --- /dev/null +++ b/src/calle/generated/models/create_goal_run_request.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, TYPE_CHECKING + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + + +if TYPE_CHECKING: + from ..models.goal_variables import GoalVariables + + +T = TypeVar("T", bound="CreateGoalRunRequest") + + +@_attrs_define +class CreateGoalRunRequest: + """One phone-specific submission against the Goal's currently published RunSpec. The object is + closed: target wrappers, per-Run region/locale/display-name hints, task text, schemas, RunSpec + selectors, provider settings, and unknown fields are not accepted. Region, callee locale, and + runtime profile come from the published Goal. Use the required `Idempotency-Key` header for + retry safety. + + Attributes: + phone (str): Recipient phone in canonical E.164 form: `+`, country code, and subscriber number with + no spaces, punctuation, or extension. The caller must be authorized to contact it. + CALL-E validates it against the published Goal's fixed Voice Target policy. + variables (GoalVariables | Unset): Dynamic variable map validated by the exact published input schema pinned + during acceptance. + Read the Goal interface rather than hard-coding undocumented keys. + """ + + phone: str + variables: GoalVariables | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + phone = self.phone + + variables: dict[str, Any] | Unset = UNSET + if not isinstance(self.variables, Unset): + variables = self.variables.to_dict() + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "phone": phone, + } + ) + if variables is not UNSET: + field_dict["variables"] = variables + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.goal_variables import GoalVariables + + d = dict(src_dict) + phone = d.pop("phone") + + _variables = d.pop("variables", UNSET) + variables: GoalVariables | Unset + if isinstance(_variables, Unset): + variables = UNSET + else: + variables = GoalVariables.from_dict(_variables) + + create_goal_run_request = cls( + phone=phone, + variables=variables, + ) + + return create_goal_run_request diff --git a/src/calle/generated/models/developer_event.py b/src/calle/generated/models/developer_event.py index 6073bc2..5ebca9f 100644 --- a/src/calle/generated/models/developer_event.py +++ b/src/calle/generated/models/developer_event.py @@ -1,13 +1,16 @@ from __future__ import annotations -import datetime from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import Any, TypeVar, TYPE_CHECKING from attrs import define as _attrs_define -from ..models.call_status import CallStatus, check_call_status -from ..models.developer_event_level import DeveloperEventLevel, check_developer_event_level + +from ..models.call_status import CallStatus +from ..models.call_status import check_call_status +from ..models.developer_event_level import check_developer_event_level +from ..models.developer_event_level import DeveloperEventLevel +import datetime if TYPE_CHECKING: from ..models.developer_event_details import DeveloperEventDetails @@ -25,7 +28,8 @@ class DeveloperEvent: call_id (str): Public CALL-E call identifier associated with this event. created_at (datetime.datetime): ISO 8601 timestamp when the event was emitted. level (DeveloperEventLevel): Event severity for log routing and alerting. - status (CallStatus): Current lifecycle state of a CALL-E call. + status (CallStatus): Current lifecycle state of a CALL-E call. `in_progress` includes post-call result + finalization; terminal states are published only after the post-call outcome is available. message (str): Short human-readable event message. details (DeveloperEventDetails): Event-specific structured details. Shape depends on the event type. """ diff --git a/src/calle/generated/models/developer_event_details.py b/src/calle/generated/models/developer_event_details.py index e0897ab..7175300 100644 --- a/src/calle/generated/models/developer_event_details.py +++ b/src/calle/generated/models/developer_event_details.py @@ -6,6 +6,7 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field + T = TypeVar("T", bound="DeveloperEventDetails") diff --git a/src/calle/generated/models/developer_event_level.py b/src/calle/generated/models/developer_event_level.py index 5f426cf..0b328c5 100644 --- a/src/calle/generated/models/developer_event_level.py +++ b/src/calle/generated/models/developer_event_level.py @@ -13,4 +13,6 @@ def check_developer_event_level(value: str) -> DeveloperEventLevel: if value in DEVELOPER_EVENT_LEVEL_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {DEVELOPER_EVENT_LEVEL_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {DEVELOPER_EVENT_LEVEL_VALUES!r}" + ) diff --git a/src/calle/generated/models/error_envelope.py b/src/calle/generated/models/error_envelope.py index 205e184..aace253 100644 --- a/src/calle/generated/models/error_envelope.py +++ b/src/calle/generated/models/error_envelope.py @@ -1,10 +1,11 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import Any, TypeVar, TYPE_CHECKING from attrs import define as _attrs_define + if TYPE_CHECKING: from ..models.api_error import APIError diff --git a/src/calle/generated/models/event_list.py b/src/calle/generated/models/event_list.py index c731951..e88ca7b 100644 --- a/src/calle/generated/models/event_list.py +++ b/src/calle/generated/models/event_list.py @@ -1,13 +1,16 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import Any, TypeVar, TYPE_CHECKING from attrs import define as _attrs_define -from ..models.event_list_object import EventListObject, check_event_list_object from ..types import UNSET, Unset +from ..models.event_list_object import check_event_list_object +from ..models.event_list_object import EventListObject +from typing import cast + if TYPE_CHECKING: from ..models.developer_event import DeveloperEvent diff --git a/src/calle/generated/models/event_list_object.py b/src/calle/generated/models/event_list_object.py index 5216a59..15a4b70 100644 --- a/src/calle/generated/models/event_list_object.py +++ b/src/calle/generated/models/event_list_object.py @@ -10,4 +10,6 @@ def check_event_list_object(value: str) -> EventListObject: if value in EVENT_LIST_OBJECT_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {EVENT_LIST_OBJECT_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {EVENT_LIST_OBJECT_VALUES!r}" + ) diff --git a/src/calle/generated/models/goal.py b/src/calle/generated/models/goal.py new file mode 100644 index 0000000..5da9aed --- /dev/null +++ b/src/calle/generated/models/goal.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, TYPE_CHECKING + +from attrs import define as _attrs_define + + +from ..models.goal_object import check_goal_object +from ..models.goal_object import GoalObject +from ..models.goal_status import check_goal_status +from ..models.goal_status import GoalStatus +from typing import cast + +if TYPE_CHECKING: + from ..models.goal_published_run_spec import GoalPublishedRunSpec + + +T = TypeVar("T", bound="Goal") + + +@_attrs_define +class Goal: + """Owner-scoped active Goal and its currently published immutable RunSpec interface. This is an + execution contract, not an authoring record: it includes a developer-facing title and + description but omits prompts, provider settings, and history. + + Attributes: + object_ (GoalObject): Always `goal` for Goal responses. + id (str): Opaque Goal identity. This is distinct from the nested RunSpec identity. + title (None | str): Short developer-facing title from the current published RunSpec. It may be null for an + untitled Goal. + description (str): Developer-facing summary of what the current published Goal does. This is not the execution + prompt. + status (GoalStatus): Always `active`; non-executable Goals are not returned by this surface. + published_run_spec (GoalPublishedRunSpec): Read-only published RunSpec interface for a Goal. Applications should + inspect the schemas + before constructing variables and should record the version used by deployments. + """ + + object_: GoalObject + id: str + title: None | str + description: str + status: GoalStatus + published_run_spec: GoalPublishedRunSpec + + def to_dict(self) -> dict[str, Any]: + object_: str = self.object_ + + id = self.id + + title: None | str + title = self.title + + description = self.description + + status: str = self.status + + published_run_spec = self.published_run_spec.to_dict() + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "object": object_, + "id": id, + "title": title, + "description": description, + "status": status, + "published_run_spec": published_run_spec, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.goal_published_run_spec import GoalPublishedRunSpec + + d = dict(src_dict) + object_ = check_goal_object(d.pop("object")) + + id = d.pop("id") + + def _parse_title(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + title = _parse_title(d.pop("title")) + + description = d.pop("description") + + status = check_goal_status(d.pop("status")) + + published_run_spec = GoalPublishedRunSpec.from_dict(d.pop("published_run_spec")) + + goal = cls( + object_=object_, + id=id, + title=title, + description=description, + status=status, + published_run_spec=published_run_spec, + ) + + return goal diff --git a/src/calle/generated/models/goal_list.py b/src/calle/generated/models/goal_list.py new file mode 100644 index 0000000..f071b66 --- /dev/null +++ b/src/calle/generated/models/goal_list.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, TYPE_CHECKING + +from attrs import define as _attrs_define + + +from ..models.goal_list_object import check_goal_list_object +from ..models.goal_list_object import GoalListObject +from typing import cast + +if TYPE_CHECKING: + from ..models.goal import Goal + + +T = TypeVar("T", bound="GoalList") + + +@_attrs_define +class GoalList: + """Cursor-paginated collection of the authenticated owner's listed, active, published Goal + interfaces. Use this for discovery or recovery of a known Goal id, not as title search. + + Attributes: + object_ (GoalListObject): Always `list` for paginated list responses. + data (list[Goal]): Goal interfaces in stable opaque-id order. Do not assume the first item is the Goal your + workflow should execute; store the intended `goal_id` when it is published. + next_cursor (None | str): Opaque cursor for the next page. `null` means there are no more Goals. + """ + + object_: GoalListObject + data: list[Goal] + next_cursor: None | str + + def to_dict(self) -> dict[str, Any]: + object_: str = self.object_ + + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + next_cursor: None | str + next_cursor = self.next_cursor + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "object": object_, + "data": data, + "next_cursor": next_cursor, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.goal import Goal + + d = dict(src_dict) + object_ = check_goal_list_object(d.pop("object")) + + data = [] + _data = d.pop("data") + for data_item_data in _data: + data_item = Goal.from_dict(data_item_data) + + data.append(data_item) + + def _parse_next_cursor(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + next_cursor = _parse_next_cursor(d.pop("next_cursor")) + + goal_list = cls( + object_=object_, + data=data, + next_cursor=next_cursor, + ) + + return goal_list diff --git a/src/calle/generated/models/goal_list_object.py b/src/calle/generated/models/goal_list_object.py new file mode 100644 index 0000000..f618cb9 --- /dev/null +++ b/src/calle/generated/models/goal_list_object.py @@ -0,0 +1,15 @@ +from typing import Literal + +GoalListObject = Literal["list"] + +GOAL_LIST_OBJECT_VALUES: set[GoalListObject] = { + "list", +} + + +def check_goal_list_object(value: str) -> GoalListObject: + if value in GOAL_LIST_OBJECT_VALUES: + return value + raise TypeError( + f"Unexpected value {value!r}. Expected one of {GOAL_LIST_OBJECT_VALUES!r}" + ) diff --git a/src/calle/generated/models/goal_object.py b/src/calle/generated/models/goal_object.py new file mode 100644 index 0000000..5ba23d2 --- /dev/null +++ b/src/calle/generated/models/goal_object.py @@ -0,0 +1,15 @@ +from typing import Literal + +GoalObject = Literal["goal"] + +GOAL_OBJECT_VALUES: set[GoalObject] = { + "goal", +} + + +def check_goal_object(value: str) -> GoalObject: + if value in GOAL_OBJECT_VALUES: + return value + raise TypeError( + f"Unexpected value {value!r}. Expected one of {GOAL_OBJECT_VALUES!r}" + ) diff --git a/src/calle/generated/models/goal_published_run_spec.py b/src/calle/generated/models/goal_published_run_spec.py new file mode 100644 index 0000000..73ad1b2 --- /dev/null +++ b/src/calle/generated/models/goal_published_run_spec.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, TYPE_CHECKING + +from attrs import define as _attrs_define + + +if TYPE_CHECKING: + from ..models.goal_published_run_spec_input_schema import ( + GoalPublishedRunSpecInputSchema, + ) + from ..models.goal_published_run_spec_result_schema import ( + GoalPublishedRunSpecResultSchema, + ) + + +T = TypeVar("T", bound="GoalPublishedRunSpec") + + +@_attrs_define +class GoalPublishedRunSpec: + """Read-only published RunSpec interface for a Goal. Applications should inspect the schemas + before constructing variables and should record the version used by deployments. + + Attributes: + id (str): Opaque immutable RunSpec identity. + version (int): Monotonic published version within this Goal. A later publish affects only new Runs. + input_schema (GoalPublishedRunSpecInputSchema): Normalized JSON Schema for per-Run `variables`. Respect + `required`, property types, enum + values, defaults, and `additionalProperties`; invalid input is rejected before execution. + result_schema (GoalPublishedRunSpecResultSchema): Normalized JSON Schema for `result`. CALL-E exposes a result + only after it is + validated against this exact pinned schema and durably persisted. + """ + + id: str + version: int + input_schema: GoalPublishedRunSpecInputSchema + result_schema: GoalPublishedRunSpecResultSchema + + def to_dict(self) -> dict[str, Any]: + id = self.id + + version = self.version + + input_schema = self.input_schema.to_dict() + + result_schema = self.result_schema.to_dict() + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "id": id, + "version": version, + "input_schema": input_schema, + "result_schema": result_schema, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.goal_published_run_spec_input_schema import ( + GoalPublishedRunSpecInputSchema, + ) + from ..models.goal_published_run_spec_result_schema import ( + GoalPublishedRunSpecResultSchema, + ) + + d = dict(src_dict) + id = d.pop("id") + + version = d.pop("version") + + input_schema = GoalPublishedRunSpecInputSchema.from_dict(d.pop("input_schema")) + + result_schema = GoalPublishedRunSpecResultSchema.from_dict( + d.pop("result_schema") + ) + + goal_published_run_spec = cls( + id=id, + version=version, + input_schema=input_schema, + result_schema=result_schema, + ) + + return goal_published_run_spec diff --git a/src/calle/generated/models/goal_published_run_spec_input_schema.py b/src/calle/generated/models/goal_published_run_spec_input_schema.py new file mode 100644 index 0000000..fb211ea --- /dev/null +++ b/src/calle/generated/models/goal_published_run_spec_input_schema.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +T = TypeVar("T", bound="GoalPublishedRunSpecInputSchema") + + +@_attrs_define +class GoalPublishedRunSpecInputSchema: + """Normalized JSON Schema for per-Run `variables`. Respect `required`, property types, enum + values, defaults, and `additionalProperties`; invalid input is rejected before execution. + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + goal_published_run_spec_input_schema = cls() + + goal_published_run_spec_input_schema.additional_properties = d + return goal_published_run_spec_input_schema + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/calle/generated/models/goal_published_run_spec_result_schema.py b/src/calle/generated/models/goal_published_run_spec_result_schema.py new file mode 100644 index 0000000..29a5bf0 --- /dev/null +++ b/src/calle/generated/models/goal_published_run_spec_result_schema.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +T = TypeVar("T", bound="GoalPublishedRunSpecResultSchema") + + +@_attrs_define +class GoalPublishedRunSpecResultSchema: + """Normalized JSON Schema for `result`. CALL-E exposes a result only after it is + validated against this exact pinned schema and durably persisted. + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + goal_published_run_spec_result_schema = cls() + + goal_published_run_spec_result_schema.additional_properties = d + return goal_published_run_spec_result_schema + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/calle/generated/models/goal_run.py b/src/calle/generated/models/goal_run.py new file mode 100644 index 0000000..2e68e20 --- /dev/null +++ b/src/calle/generated/models/goal_run.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, TYPE_CHECKING + +from attrs import define as _attrs_define + + +from ..models.goal_run_object import check_goal_run_object +from ..models.goal_run_object import GoalRunObject +from ..models.goal_run_status import check_goal_run_status +from ..models.goal_run_status import GoalRunStatus +from typing import cast +import datetime + +if TYPE_CHECKING: + from ..models.goal_run_error import GoalRunError + from ..models.goal_run_result_type_0 import GoalRunResultType0 + from ..models.goal_run_spec_snapshot import GoalRunSpecSnapshot + + +T = TypeVar("T", bound="GoalRun") + + +@_attrs_define +class GoalRun: + """Public projection of one phone-specific execution of a published Goal. A non-null `result` + is a successfully parsed and persisted object. A non-null `error` means the Run will not + produce a result. When both are null, continue polling. + + Attributes: + object_ (GoalRunObject): + id (str): Public Goal Run identity. Persist this value and use it as `goal_run_id` when polling. + goal_id (str): Goal identity supplied in the create path. + run_id (str): Internal execution member exposed for correlation; do not use it in the Goal Run polling path. + run_spec (GoalRunSpecSnapshot): Exact immutable RunSpec identity and version pinned by a Goal Run. + status (GoalRunStatus): Stable telephone execution state. `queued` and `in_progress` are non-terminal; + `completed`, + `failed`, and `canceled` are terminal. A completed call can still have `result: null` and + `error: null` briefly while CALL-E parses and saves the result. + result (GoalRunResultType0 | None): Parsed result validated against the published result schema and durably + persisted, or + `null` while processing or when the Run has an error. Its keys vary by Goal. + error (GoalRunError | None): Unified execution or result-processing error, or `null`. Branch on `code`; keep + `message` + for logs and operators. A non-null error is final and is mutually exclusive with `result`. + created_at (datetime.datetime): UTC time at which CALL-E durably accepted this Goal Run. + completed_at (datetime.datetime | None): UTC telephone-execution completion time, or `null` while execution is + non-terminal. + """ + + object_: GoalRunObject + id: str + goal_id: str + run_id: str + run_spec: GoalRunSpecSnapshot + status: GoalRunStatus + result: GoalRunResultType0 | None + error: GoalRunError | None + created_at: datetime.datetime + completed_at: datetime.datetime | None + + def to_dict(self) -> dict[str, Any]: + from ..models.goal_run_error import GoalRunError + from ..models.goal_run_result_type_0 import GoalRunResultType0 + + object_: str = self.object_ + + id = self.id + + goal_id = self.goal_id + + run_id = self.run_id + + run_spec = self.run_spec.to_dict() + + status: str = self.status + + result: dict[str, Any] | None + if isinstance(self.result, GoalRunResultType0): + result = self.result.to_dict() + else: + result = self.result + + error: dict[str, Any] | None + if isinstance(self.error, GoalRunError): + error = self.error.to_dict() + else: + error = self.error + + created_at = self.created_at.isoformat() + + completed_at: None | str + if isinstance(self.completed_at, datetime.datetime): + completed_at = self.completed_at.isoformat() + else: + completed_at = self.completed_at + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "object": object_, + "id": id, + "goal_id": goal_id, + "run_id": run_id, + "run_spec": run_spec, + "status": status, + "result": result, + "error": error, + "created_at": created_at, + "completed_at": completed_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.goal_run_error import GoalRunError + from ..models.goal_run_result_type_0 import GoalRunResultType0 + from ..models.goal_run_spec_snapshot import GoalRunSpecSnapshot + + d = dict(src_dict) + object_ = check_goal_run_object(d.pop("object")) + + id = d.pop("id") + + goal_id = d.pop("goal_id") + + run_id = d.pop("run_id") + + run_spec = GoalRunSpecSnapshot.from_dict(d.pop("run_spec")) + + status = check_goal_run_status(d.pop("status")) + + def _parse_result(data: object) -> GoalRunResultType0 | None: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + result_type_0 = GoalRunResultType0.from_dict(data) + + return result_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(GoalRunResultType0 | None, data) + + result = _parse_result(d.pop("result")) + + def _parse_error(data: object) -> GoalRunError | None: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + error_type_0 = GoalRunError.from_dict(data) + + return error_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(GoalRunError | None, data) + + error = _parse_error(d.pop("error")) + + created_at = datetime.datetime.fromisoformat(d.pop("created_at")) + + def _parse_completed_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + completed_at_type_0 = datetime.datetime.fromisoformat(data) + + return completed_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + completed_at = _parse_completed_at(d.pop("completed_at")) + + goal_run = cls( + object_=object_, + id=id, + goal_id=goal_id, + run_id=run_id, + run_spec=run_spec, + status=status, + result=result, + error=error, + created_at=created_at, + completed_at=completed_at, + ) + + return goal_run diff --git a/src/calle/generated/models/goal_run_error.py b/src/calle/generated/models/goal_run_error.py new file mode 100644 index 0000000..ae09263 --- /dev/null +++ b/src/calle/generated/models/goal_run_error.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define + + +from ..models.goal_run_error_code import check_goal_run_error_code +from ..models.goal_run_error_code import GoalRunErrorCode +from typing import cast + + +T = TypeVar("T", bound="GoalRunError") + + +@_attrs_define +class GoalRunError: + """Unified safe error returned when a Goal Run cannot produce a usable result. + + Attributes: + code (GoalRunErrorCode): + message (str): Human-readable safe explanation. Do not parse this field for application logic. + detail_code (None | str): Optional low-cardinality diagnostic detail safe for logs or narrow application + branching. + """ + + code: GoalRunErrorCode + message: str + detail_code: None | str + + def to_dict(self) -> dict[str, Any]: + code: str = self.code + + message = self.message + + detail_code: None | str + detail_code = self.detail_code + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "code": code, + "message": message, + "detail_code": detail_code, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + code = check_goal_run_error_code(d.pop("code")) + + message = d.pop("message") + + def _parse_detail_code(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + detail_code = _parse_detail_code(d.pop("detail_code")) + + goal_run_error = cls( + code=code, + message=message, + detail_code=detail_code, + ) + + return goal_run_error diff --git a/src/calle/generated/models/goal_run_error_code.py b/src/calle/generated/models/goal_run_error_code.py new file mode 100644 index 0000000..edf42be --- /dev/null +++ b/src/calle/generated/models/goal_run_error_code.py @@ -0,0 +1,31 @@ +from typing import Literal + +GoalRunErrorCode = Literal[ + "call_failed", + "canceled", + "declined", + "no_answer", + "result_failed", + "result_invalid", + "result_unavailable", + "timed_out", +] + +GOAL_RUN_ERROR_CODE_VALUES: set[GoalRunErrorCode] = { + "call_failed", + "canceled", + "declined", + "no_answer", + "result_failed", + "result_invalid", + "result_unavailable", + "timed_out", +} + + +def check_goal_run_error_code(value: str) -> GoalRunErrorCode: + if value in GOAL_RUN_ERROR_CODE_VALUES: + return value + raise TypeError( + f"Unexpected value {value!r}. Expected one of {GOAL_RUN_ERROR_CODE_VALUES!r}" + ) diff --git a/src/calle/generated/models/goal_run_object.py b/src/calle/generated/models/goal_run_object.py new file mode 100644 index 0000000..0192278 --- /dev/null +++ b/src/calle/generated/models/goal_run_object.py @@ -0,0 +1,15 @@ +from typing import Literal + +GoalRunObject = Literal["goal_run"] + +GOAL_RUN_OBJECT_VALUES: set[GoalRunObject] = { + "goal_run", +} + + +def check_goal_run_object(value: str) -> GoalRunObject: + if value in GOAL_RUN_OBJECT_VALUES: + return value + raise TypeError( + f"Unexpected value {value!r}. Expected one of {GOAL_RUN_OBJECT_VALUES!r}" + ) diff --git a/src/calle/generated/models/goal_run_result_type_0.py b/src/calle/generated/models/goal_run_result_type_0.py new file mode 100644 index 0000000..3dddc86 --- /dev/null +++ b/src/calle/generated/models/goal_run_result_type_0.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +from typing import cast + + +T = TypeVar("T", bound="GoalRunResultType0") + + +@_attrs_define +class GoalRunResultType0: + """Parsed result validated against the published result schema and durably persisted, or + `null` while processing or when the Run has an error. Its keys vary by Goal. + + """ + + additional_properties: dict[str, bool | float | str] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + goal_run_result_type_0 = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property(data: object) -> bool | float | str: + return cast(bool | float | str, data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + goal_run_result_type_0.additional_properties = additional_properties + return goal_run_result_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool | float | str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool | float | str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/calle/generated/models/goal_run_spec_snapshot.py b/src/calle/generated/models/goal_run_spec_snapshot.py new file mode 100644 index 0000000..fcbf6de --- /dev/null +++ b/src/calle/generated/models/goal_run_spec_snapshot.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define + + +T = TypeVar("T", bound="GoalRunSpecSnapshot") + + +@_attrs_define +class GoalRunSpecSnapshot: + """Exact immutable RunSpec identity and version pinned by a Goal Run. + + Attributes: + id (str): Exact immutable RunSpec id pinned when the Goal Run was accepted. + version (int): Published RunSpec version pinned for this Goal Run. + """ + + id: str + version: int + + def to_dict(self) -> dict[str, Any]: + id = self.id + + version = self.version + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "id": id, + "version": version, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + version = d.pop("version") + + goal_run_spec_snapshot = cls( + id=id, + version=version, + ) + + return goal_run_spec_snapshot diff --git a/src/calle/generated/models/goal_run_status.py b/src/calle/generated/models/goal_run_status.py new file mode 100644 index 0000000..e09c761 --- /dev/null +++ b/src/calle/generated/models/goal_run_status.py @@ -0,0 +1,19 @@ +from typing import Literal + +GoalRunStatus = Literal["canceled", "completed", "failed", "in_progress", "queued"] + +GOAL_RUN_STATUS_VALUES: set[GoalRunStatus] = { + "canceled", + "completed", + "failed", + "in_progress", + "queued", +} + + +def check_goal_run_status(value: str) -> GoalRunStatus: + if value in GOAL_RUN_STATUS_VALUES: + return value + raise TypeError( + f"Unexpected value {value!r}. Expected one of {GOAL_RUN_STATUS_VALUES!r}" + ) diff --git a/src/calle/generated/models/goal_status.py b/src/calle/generated/models/goal_status.py new file mode 100644 index 0000000..1fa39f8 --- /dev/null +++ b/src/calle/generated/models/goal_status.py @@ -0,0 +1,15 @@ +from typing import Literal + +GoalStatus = Literal["active"] + +GOAL_STATUS_VALUES: set[GoalStatus] = { + "active", +} + + +def check_goal_status(value: str) -> GoalStatus: + if value in GOAL_STATUS_VALUES: + return value + raise TypeError( + f"Unexpected value {value!r}. Expected one of {GOAL_STATUS_VALUES!r}" + ) diff --git a/src/calle/generated/models/goal_variables.py b/src/calle/generated/models/goal_variables.py new file mode 100644 index 0000000..abb78a2 --- /dev/null +++ b/src/calle/generated/models/goal_variables.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +from typing import cast + + +T = TypeVar("T", bound="GoalVariables") + + +@_attrs_define +class GoalVariables: + """Dynamic variable map validated by the exact published input schema pinned during acceptance. + Read the Goal interface rather than hard-coding undocumented keys. + + """ + + additional_properties: dict[str, bool | float | str] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + goal_variables = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property(data: object) -> bool | float | str: + return cast(bool | float | str, data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + goal_variables.additional_properties = additional_properties + return goal_variables + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool | float | str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool | float | str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/calle/generated/models/recipient_status.py b/src/calle/generated/models/recipient_status.py index ed9ba24..1a58051 100644 --- a/src/calle/generated/models/recipient_status.py +++ b/src/calle/generated/models/recipient_status.py @@ -14,4 +14,6 @@ def check_recipient_status(value: str) -> RecipientStatus: if value in RECIPIENT_STATUS_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {RECIPIENT_STATUS_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {RECIPIENT_STATUS_VALUES!r}" + ) diff --git a/src/calle/generated/models/transcript_speaker.py b/src/calle/generated/models/transcript_speaker.py index 195cbf2..c72c8ff 100644 --- a/src/calle/generated/models/transcript_speaker.py +++ b/src/calle/generated/models/transcript_speaker.py @@ -12,4 +12,6 @@ def check_transcript_speaker(value: str) -> TranscriptSpeaker: if value in TRANSCRIPT_SPEAKER_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {TRANSCRIPT_SPEAKER_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {TRANSCRIPT_SPEAKER_VALUES!r}" + ) diff --git a/src/calle/generated/models/webhook_acknowledgement.py b/src/calle/generated/models/webhook_acknowledgement.py index fe841cd..888c91b 100644 --- a/src/calle/generated/models/webhook_acknowledgement.py +++ b/src/calle/generated/models/webhook_acknowledgement.py @@ -8,6 +8,7 @@ from ..types import UNSET, Unset + T = TypeVar("T", bound="WebhookAcknowledgement") diff --git a/src/calle/generated/models/webhook_event_type.py b/src/calle/generated/models/webhook_event_type.py index 735ef50..3c8e088 100644 --- a/src/calle/generated/models/webhook_event_type.py +++ b/src/calle/generated/models/webhook_event_type.py @@ -1,6 +1,8 @@ from typing import Literal -WebhookEventType = Literal["call.completed", "call.failed", "call.result_validation_failed"] +WebhookEventType = Literal[ + "call.completed", "call.failed", "call.result_validation_failed" +] WEBHOOK_EVENT_TYPE_VALUES: set[WebhookEventType] = { "call.completed", @@ -12,4 +14,6 @@ def check_webhook_event_type(value: str) -> WebhookEventType: if value in WEBHOOK_EVENT_TYPE_VALUES: return value - raise TypeError(f"Unexpected value {value!r}. Expected one of {WEBHOOK_EVENT_TYPE_VALUES!r}") + raise TypeError( + f"Unexpected value {value!r}. Expected one of {WEBHOOK_EVENT_TYPE_VALUES!r}" + ) diff --git a/src/calle/generated/types.py b/src/calle/generated/types.py index b64af09..7e8ac3c 100644 --- a/src/calle/generated/types.py +++ b/src/calle/generated/types.py @@ -2,7 +2,7 @@ from collections.abc import Mapping, MutableMapping from http import HTTPStatus -from typing import IO, BinaryIO, Generic, Literal, TypeVar +from typing import BinaryIO, Generic, TypeVar, Literal, IO from attrs import define diff --git a/src/calle/goals.py b/src/calle/goals.py new file mode 100644 index 0000000..17592c5 --- /dev/null +++ b/src/calle/goals.py @@ -0,0 +1,143 @@ +import math +import time +from typing import Any +from urllib.parse import quote + +import httpx + +from calle.errors import CalleConnectionError, CalleTimeoutError, api_error_from_response + + +JsonObject = dict[str, Any] +GoalScalar = str | int | float | bool +GoalVariables = dict[str, GoalScalar] + + +class CalleGoals: + def __init__(self, *, client: httpx.Client) -> None: + self._client = client + + def list(self, *, limit: int = 20, after: str | None = None) -> JsonObject: + params: dict[str, str | int] = {"limit": limit} + if after is not None: + params["after"] = after + return self._request("GET", "/v1/goals", params=params) + + def get(self, goal_id: str) -> JsonObject: + encoded_goal_id = quote(goal_id, safe="") + return self._request("GET", f"/v1/goals/{encoded_goal_id}") + + def run( + self, + *, + goal_id: str, + phone: str, + variables: GoalVariables | None = None, + idempotency_key: str, + ) -> JsonObject: + body: JsonObject = {"phone": phone} + if variables is not None: + body["variables"] = variables + encoded_goal_id = quote(goal_id, safe="") + return self._request( + "POST", + f"/v1/goals/{encoded_goal_id}/runs", + json=body, + headers={"Idempotency-Key": idempotency_key}, + ) + + def get_run(self, goal_id: str, goal_run_id: str) -> JsonObject: + return self._get_run(goal_id, goal_run_id) + + def _get_run( + self, + goal_id: str, + goal_run_id: str, + *, + timeout_seconds: float | None = None, + ) -> JsonObject: + encoded_goal_id = quote(goal_id, safe="") + encoded_goal_run_id = quote(goal_run_id, safe="") + request_options: dict[str, Any] = {} + if timeout_seconds is not None: + request_options["timeout"] = timeout_seconds + return self._request( + "GET", + f"/v1/goals/{encoded_goal_id}/runs/{encoded_goal_run_id}", + **request_options, + ) + + def wait_for_result( + self, + goal_id: str, + goal_run_id: str, + *, + interval_seconds: float = 2.0, + timeout_seconds: float = 600.0, + ) -> JsonObject: + _validate_polling_seconds(interval_seconds, "interval_seconds") + _validate_polling_seconds(timeout_seconds, "timeout_seconds") + deadline = time.monotonic() + timeout_seconds + while True: + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + break + run = self._get_run( + goal_id, + goal_run_id, + timeout_seconds=remaining_seconds, + ) + if run.get("result") is not None or run.get("error") is not None: + return run + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + break + time.sleep(min(interval_seconds, remaining_seconds)) + raise CalleTimeoutError(f"Timed out waiting for CALL-E Goal Run {goal_run_id}.") + + def run_and_wait( + self, + *, + goal_id: str, + phone: str, + variables: GoalVariables | None = None, + idempotency_key: str, + interval_seconds: float = 2.0, + timeout_seconds: float = 600.0, + ) -> JsonObject: + _validate_polling_seconds(interval_seconds, "interval_seconds") + _validate_polling_seconds(timeout_seconds, "timeout_seconds") + run = self.run( + goal_id=goal_id, + phone=phone, + variables=variables, + idempotency_key=idempotency_key, + ) + if run.get("result") is not None or run.get("error") is not None: + return run + return self.wait_for_result( + goal_id, + str(run["id"]), + interval_seconds=interval_seconds, + timeout_seconds=timeout_seconds, + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> JsonObject: + try: + response = self._client.request(method, path, **kwargs) + except httpx.TimeoutException as exc: + raise CalleTimeoutError("CALL-E API request timed out.") from exc + except httpx.HTTPError as exc: + raise CalleConnectionError("CALL-E API request failed before receiving a response.") from exc + + if response.status_code >= 400: + raise api_error_from_response(response.status_code, response.json()) + payload = response.json() + if not isinstance(payload, dict): + raise CalleConnectionError("CALL-E API returned a non-object JSON response.") + return payload + + +def _validate_polling_seconds(value: float, name: str) -> None: + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} must be a finite positive number.") diff --git a/src/calle/webhooks.py b/src/calle/webhooks.py index 9f9a3a3..0a04351 100644 --- a/src/calle/webhooks.py +++ b/src/calle/webhooks.py @@ -1,6 +1,6 @@ +import hashlib import hmac import json -import hashlib from typing import Any, Mapping from calle.errors import CalleWebhookSignatureError @@ -8,10 +8,21 @@ class CalleWebhooks: def verify(self, *, raw_body: bytes | str, timestamp: str, signature: str, secret: str) -> bool: + """Verify a legacy HMAC signature. + + Deprecated: CALL-E no longer sends timestamp or signature headers. This + method remains available for integrations that use their own compatible + signing layer. + """ expected = _signature(raw_body=raw_body, timestamp=timestamp, secret=secret) return hmac.compare_digest(signature, expected) def unwrap(self, *, raw_body: bytes | str, headers: Mapping[str, str], secret: str) -> dict[str, Any]: + """Verify and parse a legacy signed webhook payload. + + Deprecated: current CALL-E webhooks are unsigned. This method retains the + SDK 0.2 trust boundary and must not be used to parse current deliveries. + """ timestamp = _header(headers, "CALL-E-Timestamp") signature = _header(headers, "CALL-E-Signature") if timestamp is None or signature is None: diff --git a/tests/test_goal_example.py b/tests/test_goal_example.py new file mode 100644 index 0000000..09cba9c --- /dev/null +++ b/tests/test_goal_example.py @@ -0,0 +1,48 @@ +import runpy +from collections.abc import Callable +from pathlib import Path +from typing import Any, cast + +import pytest + + +example = runpy.run_path( + str(Path(__file__).parent.parent / "examples" / "run_goal_and_wait.py") +) +goal_variables = cast( + Callable[[], dict[str, str | int | float | bool]], + example["goal_variables"], +) +exit_on_goal_error = cast( + Callable[[dict[str, Any]], None], + example["exit_on_goal_error"], +) + +@pytest.mark.parametrize("variables", ['{"score":NaN}', '{"score":Infinity}', '{"score":1e999}']) +def test_goal_example_rejects_non_finite_variables( + monkeypatch: pytest.MonkeyPatch, + variables: str, +) -> None: + monkeypatch.setenv("CALLE_GOAL_VARIABLES", variables) + + with pytest.raises(RuntimeError): + goal_variables() + + +def test_goal_example_exits_nonzero_for_domain_error() -> None: + with pytest.raises(SystemExit) as exc_info: + exit_on_goal_error( + { + "error": { + "code": "no_answer", + "message": "No human answered.", + "detail_code": None, + } + } + ) + + assert exc_info.value.code == 1 + + +def test_goal_example_accepts_success() -> None: + exit_on_goal_error({"error": None}) diff --git a/tests/test_goals.py b/tests/test_goals.py new file mode 100644 index 0000000..3dee33d --- /dev/null +++ b/tests/test_goals.py @@ -0,0 +1,413 @@ +import json + +import httpx +import pytest +import respx + +import calle.goals as goals_module +from calle import CalleClient +from calle.errors import CalleAPIError, CalleTimeoutError +from calle.generated.models import Goal, GoalRun + + +PUBLISHED_GOAL = { + "object": "goal", + "id": "goal_delivery", + "title": "Delivery window confirmation", + "description": "Confirm a delivery window or collect a preferred alternative.", + "status": "active", + "published_run_spec": { + "id": "rspec_delivery_v4", + "version": 4, + "input_schema": { + "type": "object", + "required": ["order_reference"], + "properties": {"order_reference": {"type": "string"}}, + }, + "result_schema": { + "type": "object", + "required": ["delivery_outcome"], + "properties": {"delivery_outcome": {"type": "string"}}, + }, + }, +} + +QUEUED_RUN = { + "object": "goal_run", + "id": "rgrp_delivery_8472", + "goal_id": "goal_delivery", + "run_id": "run_delivery_8472", + "run_spec": {"id": "rspec_delivery_v4", "version": 4}, + "status": "queued", + "result": None, + "error": None, + "created_at": "2026-07-22T10:00:00Z", + "completed_at": None, +} + + +def test_generated_goal_models_parse_public_api_examples() -> None: + goal = Goal.from_dict(PUBLISHED_GOAL) + run = GoalRun.from_dict(QUEUED_RUN) + + assert goal.id == "goal_delivery" + assert goal.published_run_spec.version == 4 + assert run.id == "rgrp_delivery_8472" + assert run.run_spec.id == "rspec_delivery_v4" + + +@respx.mock +def test_list_goals_uses_default_production_url_and_pagination() -> None: + route = respx.get("https://api.heycall-e.com/v1/goals").mock( + return_value=httpx.Response( + 200, + json={"object": "list", "data": [PUBLISHED_GOAL], "next_cursor": "goalcur_later"}, + ) + ) + client = CalleClient(api_key="key_test") + + goals = client.goals.list(limit=10, after="goalcur_next") + + request = route.calls.last.request + assert request.headers["authorization"] == "Bearer key_test" + assert dict(request.url.params) == {"limit": "10", "after": "goalcur_next"} + assert goals["next_cursor"] == "goalcur_later" + assert goals["data"][0]["title"] == "Delivery window confirmation" + assert goals["data"][0]["published_run_spec"]["input_schema"] == PUBLISHED_GOAL["published_run_spec"][ + "input_schema" + ] + + +@respx.mock +def test_get_goal_returns_published_interface() -> None: + route = respx.get("https://api.heycall-e.com/v1/goals/goal_delivery").mock( + return_value=httpx.Response(200, json=PUBLISHED_GOAL) + ) + client = CalleClient(api_key="key_test") + + goal = client.goals.get("goal_delivery") + + assert route.called + assert goal["published_run_spec"]["result_schema"] == PUBLISHED_GOAL["published_run_spec"]["result_schema"] + + +@respx.mock +def test_goal_paths_percent_encode_opaque_ids() -> None: + get_goal_route = respx.get("https://api.heycall-e.com/v1/goals/goal%2Fdelivery").mock( + return_value=httpx.Response(200, json=PUBLISHED_GOAL) + ) + create_run_route = respx.post("https://api.heycall-e.com/v1/goals/goal%2Fdelivery/runs").mock( + return_value=httpx.Response(201, json=QUEUED_RUN) + ) + get_run_route = respx.get( + "https://api.heycall-e.com/v1/goals/goal%2Fdelivery/runs/rgrp%2F8472%3Fview%3D1" + ).mock(return_value=httpx.Response(200, json=QUEUED_RUN)) + client = CalleClient(api_key="key_test") + + client.goals.get("goal/delivery") + client.goals.run( + goal_id="goal/delivery", + phone="+14155550100", + idempotency_key="delivery:ORD-8472:v1", + ) + client.goals.get_run("goal/delivery", "rgrp/8472?view=1") + + assert get_goal_route.called + assert create_run_route.called + assert get_run_route.called + + +@respx.mock +def test_run_goal_sends_only_phone_variables_and_idempotency_identity() -> None: + route = respx.post("https://api.heycall-e.com/v1/goals/goal_delivery/runs").mock( + return_value=httpx.Response(201, json=QUEUED_RUN) + ) + client = CalleClient(api_key="key_test") + + run = client.goals.run( + goal_id="goal_delivery", + phone="+14155550100", + variables={"order_reference": "ORD-8472"}, + idempotency_key="delivery:ORD-8472:v1", + ) + + request = route.calls.last.request + assert request.headers["idempotency-key"] == "delivery:ORD-8472:v1" + assert json.loads(request.content) == { + "phone": "+14155550100", + "variables": {"order_reference": "ORD-8472"}, + } + assert run["id"] == "rgrp_delivery_8472" + assert run["run_spec"] == {"id": "rspec_delivery_v4", "version": 4} + + +@respx.mock +def test_wait_for_result_ignores_completed_status_until_result_exists() -> None: + materializing = { + **QUEUED_RUN, + "status": "completed", + "completed_at": "2026-07-22T10:01:00Z", + } + succeeded = { + **materializing, + "result": {"delivery_outcome": "confirmed"}, + } + route = respx.get("https://api.heycall-e.com/v1/goals/goal_delivery/runs/rgrp_delivery_8472").mock( + side_effect=[ + httpx.Response(200, json=materializing), + httpx.Response(200, json=succeeded), + ] + ) + client = CalleClient(api_key="key_test") + + run = client.goals.wait_for_result( + "goal_delivery", + "rgrp_delivery_8472", + interval_seconds=0.001, + timeout_seconds=0.5, + ) + + assert run["result"] == {"delivery_outcome": "confirmed"} + assert route.call_count == 2 + + +@respx.mock +def test_wait_for_result_returns_domain_error_as_data() -> None: + failed = { + **QUEUED_RUN, + "status": "failed", + "error": { + "code": "no_answer", + "message": "No human answered the call.", + "detail_code": "provider_no_answer", + }, + "completed_at": "2026-07-22T10:01:00Z", + } + respx.get("https://api.heycall-e.com/v1/goals/goal_delivery/runs/rgrp_delivery_8472").mock( + return_value=httpx.Response(200, json=failed) + ) + client = CalleClient(api_key="key_test") + + run = client.goals.wait_for_result( + "goal_delivery", + "rgrp_delivery_8472", + interval_seconds=0.001, + timeout_seconds=0.5, + ) + + assert run["error"] == { + "code": "no_answer", + "message": "No human answered the call.", + "detail_code": "provider_no_answer", + } + + +@respx.mock +def test_run_and_wait_polls_returned_goal_run_identity() -> None: + create_route = respx.post("https://api.heycall-e.com/v1/goals/goal_delivery/runs").mock( + return_value=httpx.Response(201, json=QUEUED_RUN) + ) + poll_route = respx.get("https://api.heycall-e.com/v1/goals/goal_delivery/runs/rgrp_delivery_8472").mock( + return_value=httpx.Response( + 200, + json={ + **QUEUED_RUN, + "status": "completed", + "result": {"delivery_outcome": "confirmed"}, + "completed_at": "2026-07-22T10:01:00Z", + }, + ) + ) + client = CalleClient(api_key="key_test") + + run = client.goals.run_and_wait( + goal_id="goal_delivery", + phone="+14155550100", + idempotency_key="delivery:ORD-8472:v1", + interval_seconds=0.001, + timeout_seconds=0.5, + ) + + assert create_route.called + assert poll_route.called + assert run["result"] == {"delivery_outcome": "confirmed"} + + +@respx.mock +def test_run_and_wait_returns_terminal_idempotent_replay_without_polling() -> None: + succeeded = { + **QUEUED_RUN, + "status": "completed", + "result": {"delivery_outcome": "confirmed"}, + "completed_at": "2026-07-22T10:01:00Z", + } + create_route = respx.post("https://api.heycall-e.com/v1/goals/goal_delivery/runs").mock( + return_value=httpx.Response(201, json=succeeded) + ) + client = CalleClient(api_key="key_test") + + run = client.goals.run_and_wait( + goal_id="goal_delivery", + phone="+14155550100", + idempotency_key="delivery:ORD-8472:v1", + ) + + assert create_route.call_count == 1 + assert run["result"] == {"delivery_outcome": "confirmed"} + + +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("interval_seconds", float("nan")), + ("timeout_seconds", 0.0), + ], +) +@respx.mock +def test_run_and_wait_rejects_invalid_polling_seconds_before_create( + parameter: str, + value: float, +) -> None: + route = respx.post("https://api.heycall-e.com/v1/goals/goal_delivery/runs").mock( + return_value=httpx.Response(201, json=QUEUED_RUN) + ) + client = CalleClient(api_key="key_test") + options = {"interval_seconds": 1.0, "timeout_seconds": 1.0} + options[parameter] = value + + with pytest.raises(ValueError, match=f"{parameter} must be a finite positive number"): + client.goals.run_and_wait( + goal_id="goal_delivery", + phone="+14155550100", + idempotency_key="delivery:ORD-8472:v1", + **options, + ) + + assert route.call_count == 0 + + +@respx.mock +def test_run_goal_maps_api_error() -> None: + respx.post("https://api.heycall-e.com/v1/goals/goal_delivery/runs").mock( + return_value=httpx.Response( + 409, + json={ + "error": { + "code": "idempotency_conflict", + "message": "The key was already used with different input.", + "details": {"key": "delivery:ORD-8472:v1"}, + } + }, + ) + ) + client = CalleClient(api_key="key_test") + + with pytest.raises(CalleAPIError) as exc_info: + client.goals.run( + goal_id="goal_delivery", + phone="+14155550100", + idempotency_key="delivery:ORD-8472:v1", + ) + + assert exc_info.value.code == "idempotency_conflict" + assert exc_info.value.status_code == 409 + + +@respx.mock +def test_wait_for_result_raises_timeout_while_result_and_error_are_null() -> None: + respx.get("https://api.heycall-e.com/v1/goals/goal_delivery/runs/rgrp_delivery_8472").mock( + return_value=httpx.Response(200, json=QUEUED_RUN) + ) + client = CalleClient(api_key="key_test") + + with pytest.raises(CalleTimeoutError): + client.goals.wait_for_result( + "goal_delivery", + "rgrp_delivery_8472", + interval_seconds=0.001, + timeout_seconds=0.002, + ) + + +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("interval_seconds", 0.0), + ("interval_seconds", -1.0), + ("interval_seconds", float("nan")), + ("interval_seconds", float("inf")), + ("timeout_seconds", 0.0), + ("timeout_seconds", -1.0), + ("timeout_seconds", float("nan")), + ("timeout_seconds", float("inf")), + ], +) +@respx.mock +def test_wait_for_result_rejects_invalid_polling_seconds_without_request( + parameter: str, + value: float, +) -> None: + route = respx.get("https://api.heycall-e.com/v1/goals/goal_delivery/runs/rgrp_delivery_8472").mock( + return_value=httpx.Response(200, json=QUEUED_RUN) + ) + client = CalleClient(api_key="key_test") + options = {"interval_seconds": 1.0, "timeout_seconds": 1.0} + options[parameter] = value + + with pytest.raises(ValueError, match=f"{parameter} must be a finite positive number"): + client.goals.wait_for_result( + "goal_delivery", + "rgrp_delivery_8472", + **options, + ) + + assert route.call_count == 0 + + +@respx.mock +def test_wait_for_result_limits_poll_request_to_remaining_timeout() -> None: + route = respx.get("https://api.heycall-e.com/v1/goals/goal_delivery/runs/rgrp_delivery_8472").mock( + side_effect=httpx.ReadTimeout("poll request timed out") + ) + client = CalleClient(api_key="key_test") + + with pytest.raises(CalleTimeoutError) as exc_info: + client.goals.wait_for_result( + "goal_delivery", + "rgrp_delivery_8472", + interval_seconds=1.0, + timeout_seconds=0.25, + ) + + request_timeout = route.calls.last.request.extensions["timeout"] + assert all(0 < value <= 0.25 for value in request_timeout.values()) + assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout) + + +@respx.mock +def test_wait_for_result_does_not_sleep_past_deadline(monkeypatch: pytest.MonkeyPatch) -> None: + route = respx.get("https://api.heycall-e.com/v1/goals/goal_delivery/runs/rgrp_delivery_8472").mock( + return_value=httpx.Response(200, json=QUEUED_RUN) + ) + clock = [100.0] + sleeps: list[float] = [] + + monkeypatch.setattr(goals_module.time, "monotonic", lambda: clock[0]) + + def advance_clock(seconds: float) -> None: + sleeps.append(seconds) + clock[0] += seconds + + monkeypatch.setattr(goals_module.time, "sleep", advance_clock) + client = CalleClient(api_key="key_test") + + with pytest.raises(CalleTimeoutError): + client.goals.wait_for_result( + "goal_delivery", + "rgrp_delivery_8472", + interval_seconds=10.0, + timeout_seconds=1.0, + ) + + assert sleeps == [1.0] + assert route.call_count == 1 diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 814981e..a59c411 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -1,10 +1,10 @@ -import hmac import hashlib +import hmac +import json import pytest -from calle import CalleClient -from calle.errors import CalleWebhookSignatureError +from calle import CalleClient, CalleWebhookSignatureError def _sign(*, raw_body: bytes, timestamp: str, secret: str) -> str: @@ -12,7 +12,26 @@ def _sign(*, raw_body: bytes, timestamp: str, secret: str) -> str: return f"v1={digest}" -def test_verify_accepts_valid_signature() -> None: +def _terminal_event() -> dict[str, object]: + return { + "id": "evt_123", + "type": "call.completed", + "created_at": "2026-07-29T10:00:00Z", + "data": { + "id": "call_123", + "object": "call_task", + "status": "completed", + "structured_result": {"completed_count": 1}, + "summary": "The recipient confirmed attendance.", + "task_completed": True, + "completion_confidence": {"score": 0.92, "label": "high"}, + "evidence": ["The recipient said yes."], + "completed_at": "2026-07-29T10:00:00Z", + }, + } + + +def test_legacy_verify_accepts_valid_signature() -> None: client = CalleClient(api_key="key_test") raw_body = b'{"id":"evt_123","type":"call.completed","created_at":"2026-05-31T00:00:00Z","data":{"object":"call","id":"call_123","status":"completed"}}' timestamp = "1780035123" @@ -21,9 +40,9 @@ def test_verify_accepts_valid_signature() -> None: assert client.webhooks.verify(raw_body=raw_body, timestamp=timestamp, signature=signature, secret="whsec_dev") -def test_unwrap_returns_event() -> None: +def test_legacy_unwrap_returns_finalized_event_after_signature_verification() -> None: client = CalleClient(api_key="key_test") - raw_body = b'{"id":"evt_123","type":"call.completed","created_at":"2026-05-31T00:00:00Z","data":{"object":"call","id":"call_123","status":"completed"}}' + raw_body = json.dumps(_terminal_event()).encode() timestamp = "1780035123" signature = _sign(raw_body=raw_body, timestamp=timestamp, secret="whsec_dev") @@ -35,14 +54,36 @@ def test_unwrap_returns_event() -> None: assert event["id"] == "evt_123" assert event["type"] == "call.completed" + assert event["data"]["structured_result"] == {"completed_count": 1} + assert event["data"]["task_completed"] is True + assert event["data"]["completion_confidence"] == {"score": 0.92, "label": "high"} + assert event["data"]["evidence"] == ["The recipient said yes."] -def test_unwrap_rejects_invalid_signature() -> None: +def test_legacy_unwrap_rejects_missing_signature_headers() -> None: client = CalleClient(api_key="key_test") - with pytest.raises(CalleWebhookSignatureError): + with pytest.raises(CalleWebhookSignatureError, match="Missing"): + client.webhooks.unwrap( + raw_body=json.dumps(_terminal_event()), + headers={}, + secret="whsec_dev", + ) + + +def test_legacy_unwrap_rejects_tampered_body() -> None: + client = CalleClient(api_key="key_test") + timestamp = "1780035123" + original_body = json.dumps(_terminal_event()) + signature = _sign( + raw_body=original_body.encode(), + timestamp=timestamp, + secret="whsec_dev", + ) + + with pytest.raises(CalleWebhookSignatureError, match="Invalid"): client.webhooks.unwrap( - raw_body=b'{"id":"evt_123"}', - headers={"CALL-E-Timestamp": "1780035123", "CALL-E-Signature": "v1=bad"}, + raw_body=original_body.replace("call.completed", "call.failed"), + headers={"CALL-E-Timestamp": timestamp, "CALL-E-Signature": signature}, secret="whsec_dev", ) diff --git a/uv.lock b/uv.lock index 4e6686d..61fa50d 100644 --- a/uv.lock +++ b/uv.lock @@ -88,9 +88,10 @@ wheels = [ [[package]] name = "calle-ai" -version = "0.2.0" +version = "0.6.0" source = { editable = "." } dependencies = [ + { name = "attrs" }, { name = "httpx" }, ] @@ -105,7 +106,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = ">=0.27.0,<1.0.0" }] +requires-dist = [ + { name = "attrs", specifier = ">=22.2.0" }, + { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, +] [package.metadata.requires-dev] dev = [