Skip to main content

model sdk built by the 9th ditrict at tooig

Project description

nineth

nineth is the Python SDK for the 1984 model API, built by the 9th District at Tooig.


Install

pip install nineth
export NINETH_API_KEY="your-api-key"

How it works

Every request goes through client.model.request(...).

  • Pass a task. Get a response.
  • Set stream=True to receive text as it arrives, word by word.
  • Per request, you can opt into persistent sessions, built-in services, included services, a base-provider override, caller policy text, audio input, outbound messaging transports, JSON output, compute totals, and verbose telemetry.
  • The server still runs the worker loop and manages the actual task state.

Models

Name Description
1984-m3-0317 Most capable. Best for research and complex tasks.
1984-m2-preview Fast and powerful. Good for most tasks.
1984-m2-light Lightweight, quick general tasks.
1984-m1-unified High-throughput unified model.
1984-m0-brute Compact efficient model.
1984-m0-sm Smallest model, fastest responses.

Set a default at client creation or pass model= per call.


Cookbook

1 — Get a response

The simplest case. Ask something, get the answer.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request("Give me a tight BTC market brief.")
    print(response["final_response"])

response is a plain dict. The text is always in response["final_response"].


2 — Stream the response live

Set stream=True to print text as it arrives.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    for event in client.model.request("Summarise crude oil today.", stream=True):
        if event["type"] == "model_delta":
            print(event["data"]["text"], end="", flush=True)

The last event in the stream is type: result and contains the full final_response alongside iterations.

If continuous=True, the server keeps the worker alive after a non-terminal idle turn, but it now waits internally for interrupts or due alarms instead of spending another model turn just to emit a monitoring prompt.

Built-in service progress is streamed as {"type": "service_call", "data": {"service_name": "...", "client_managed": false}}.

  • If default_service=False, built-in service progress events are hidden.
  • If default_service=[...], only built-in service names from that allowlist are surfaced.
  • If messaging={...} is set, the SDK conditionally enables related messaging services so progress and service_response events can surface for executable messaging flows. Inbound-only template registration is treated as setup mode and does not auto-enable email send services on that registration call.
  • For email, messaging.email.email and messaging.email.name define the sender mailbox identity (from), not the eventual recipient, and messaging.email.instruction configures how that inbox should answer inbound mail.
  • Messaging-related service_response events include the sanitized raw result payload so callers can track email send, webhook, or delivery state.
  • Client-managed services included by the caller are not executed by the server. Their callback stream stays raw via model_raw_delta and client_service_call so your code can parse and execute them client-side.

3 — Choose a different model per request

from nineth import NinethClient

with NinethClient() as client:
    response = client.model.request(
        "What happened with Nvidia earnings?",
        model="1984-m2-light",
    )
    print(response["final_response"])

4 — Control reasoning depth

Use reasoning to hint at how deeply the model should think before answering. Valid values: "low", "medium", "high". Leave it out to use the model default.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Analyse the macro impact of a Fed rate pause.",
        reasoning="high",
    )
    print(response["final_response"])

5 — Show the model's reasoning

Set show_reasoning=True to include the model's internal chain-of-thought. This is off by default.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Walk me through whether gold is trending or ranging.",
        reasoning="medium",
        show_reasoning=True,
    )
    for block in response.get("thinking", []):
        print("[thinking]", block)
    print(response["final_response"])

6 — Limit how many turns the model takes

max_iterations controls how many model turns the server runs. The default is 10. Most tasks finish in 1–3 turns.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Give me a one-paragraph ETH brief.",
        max_iterations=2,
    )
    print(response["final_response"])

continuous is separate from max_iterations.

  • continuous=False: the request finishes on a non-terminal idle turn.
  • continuous=True: the server keeps the worker open and waits internally for interrupts or alarms without spending another model turn while idle.
from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Watch for the next alarm and continue only when it fires.",
        default_service=["set_alarm"],
        continuous=True,
        max_iterations=20,
    )
    print(response["final_response"])

7 — Async usage

import asyncio
from nineth import AsyncNinethClient

async def main():
    async with AsyncNinethClient(default_model="1984-m3-0317") as client:
        response = await client.model.request(
            "Summarise macro risk factors this week.",
        )
        print(response["final_response"])

asyncio.run(main())

Async streaming works the same way:

import asyncio
from nineth import AsyncNinethClient

async def main():
    async with AsyncNinethClient(default_model="1984-m3-0317") as client:
        async for event in await client.model.request(
            "Research BTC ETF flows.", stream=True
        ):
            if event["type"] == "model_delta":
                print(event["data"]["text"], end="", flush=True)

asyncio.run(main())

8 — Health check

No API key needed. Use this to verify the endpoint is reachable.

from nineth import NinethClient

with NinethClient() as client:
    print(client.health())
# {'status': 'ok', 'timestamp': '2026-04-04T00:00:00+00:00'}

9 — Provider routing

SDK requests use the base-system provider path by default. Set base_system=False only when you explicitly want to fall back to the runtime default provider selection.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0421") as client:
    response = client.model.request(
        "Summarise today's macro tape.",
        base_system=False,
    )
    print(response["final_response"])

base_system is True by default.


10 — Add caller policy text

Use policy= to add caller instructions on top of the SDK/API runtime prompt. This does not remove the runtime done or service-call rules.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Summarise today's macro tape as compact JSON.",
        policy="Return a concise JSON object with keys summary and risks.",
        response_format="json",
    )
    print(response["final_response"])

11 — Send audio input

Pass audio= as base64 strings or dicts with data, optional mime_type, and optional filename. The server stores and transcribes the audio, then injects the transcript into the task context.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Summarise the attached voice memo.",
        audio=[{"data": "BASE64_AUDIO_HERE", "mime_type": "audio/wav", "filename": "memo.wav"}],
    )
    print(response["final_response"])

12 — Request JSON output and compute totals

Use response_format="json" to ask for JSON output. When the final response is valid JSON, the SDK parses it into final_response and preserves the original text in raw_response.

Use compute=True to surface the total token count in compute. This is the total of prompt plus completion tokens.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Return the answer as JSON with keys answer and confidence.",
        response_format="json",
        compute=True,
    )
    print(response["final_response"])
    print(response["raw_response"])
    print(response["compute"])

13 — Configure mailbox messaging

Use messaging= to attach request-scoped email or Telegram transport defaults. Blank strings or default keep the server-side defaults for email sender details. For email, address and name define the sender mailbox identity (from), and instruction configures how that inbox should respond when inbound mail is processed later.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Email the latest risk summary to ops and tell me when it is sent.",
        messaging={
            "email": {
                "address": "billing-bot@resident.tooig.com",
                "name": "Billing Bot",
                "instruction": "Ask for the invoice number before confirming payment.",
            }
        },
        stream=False,
    )
    print(response["final_response"])
    print(response.get("service_responses", []))

The email status surface for that mailbox returns mailbox_config and recent_logs, including received payload summaries, model responses, and sanitized Resend state.

You can also attach ownership-aware templates for multi-mailbox workflows. This is useful when the caller identity determines both how inbound mail is interpreted and how outbound mail should be rendered.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Process review mail and respond using the owner's template.",
        messaging={
            "email": {
                "address": "reviews@resident.tooig.com",
                "name": "Review Desk",
                "instruction": "Handle inbound review requests. Always provide clear feedback.",
                "templates": [
                    {
                        "type": "inbound",
                        "name": "review_request",
                        "required": ["ticket_id", "review_url"],
                        "url": "https://api.example.com/email/review-context",
                    },
                    {
                        "type": "outbound",
                        "name": "review_reply",
                        "template_id": "review_response_2024",
                        "required": ["decision", "reviewer_name"],
                        "recipients": ["ops@resident.tooig.com"],
                    },
                ],
            }
        },
    )

Template behavior:

  • messaging.email.templates accepts inbound and outbound template definitions.
  • Inbound templates may include url, shape, both, or neither.
  • Use inbound url when template variables depend on caller-owned data; the server posts inbound context there before the model runs.
  • Inbound standalone: true means the model should prefer send_email; default behavior prefers send_reply.
  • Outbound templates require recipients; the model must provide every field listed in required.
  • Ownership verification normalizes addresses and checks that inbound mail belongs to the configured mailbox before template processing runs.

Mailbox setup behavior:

  • If a request carries inbound-only templates, that request is treated as mailbox setup and returns a setup acknowledgement instead of running a normal model send loop.
  • Setup persists mailbox configuration and can provision template shapes, but does not emit send_reply or send_email service calls for the registration request itself.
  • Real reply behavior is evaluated only when an inbound email webhook event is processed for that mailbox.
  • Setup responses include an inbound_uuid that identifies that mailbox configuration.
  • Setup is mutable: repeating setup for the same caller/mailbox updates in place instead of creating duplicates.
  • The SDK remembers the last inbound_uuid per mailbox address and automatically reuses it on later setup/delete calls unless you override it explicitly.
  • To remove setup, send messaging.email.inbound_action="delete" with either inbound_uuid or the mailbox address.

Setup acknowledgement shape:

{
    "final_response": "Inbound mailbox configuration saved. Waiting for incoming email webhook events.",
    "iterations": 0,
    "service_calls": [],
    "service_responses": [],
    "events": [
        {
            "type": "mailbox_configured",
            "data": {
                "address": "reviews@resident.tooig.com",
                "inbound_uuid": "a7e6f0d7-7e4e-4f0d-a792-4f5b0fd8f78e",
                "created_at": "...",
                "updated_at": "..."
            }
        }
    ]
}

Delete acknowledgement shape:

{
    "final_response": "Inbound mailbox configuration removed.",
    "iterations": 0,
    "service_calls": [],
    "service_responses": [],
    "events": [
        {
            "type": "mailbox_configured",
            "data": {
                "address": "reviews@resident.tooig.com",
                "inbound_uuid": "a7e6f0d7-7e4e-4f0d-a792-4f5b0fd8f78e",
                "deleted_at": "..."
            }
        }
    ]
}

Mutable setup examples:

# Explicit update by inbound_uuid
client.model.request(
    "Update mailbox setup",
    messaging={
        "email": {
            "address": "reviews@resident.tooig.com",
            "inbound_uuid": "a7e6f0d7-7e4e-4f0d-a792-4f5b0fd8f78e",
            "instruction": "Use strict triage and include ticket IDs.",
            "templates": [{"type": "inbound", "name": "review_request"}],
        }
    },
)

# Delete setup (SDK can auto-fill inbound_uuid from prior setup response)
client.model.request(
    "Remove mailbox setup",
    messaging={
        "email": {
            "address": "reviews@resident.tooig.com",
            "inbound_action": "delete",
        }
    },
)

Inbound callback payloads include fields such as from, to, subject, text, and message_id. The callback must return a JSON object of template variables, for example:

{
    "ticket_id": "rvw-123",
    "review_url": "https://portal.example.com/reviews/rvw-123"
}

When the model answers with send_email or send_reply, it can target the owner template explicitly:

{
    "send_reply": {
        "owner_template_name": "review_reply",
        "owner_template_params": {
            "decision": "approved",
            "reviewer_name": "A. Chen"
        }
    }
}

The server validates the required template parameters before dispatch and passes them through to the email provider as template variables.

Parameter handling is split cleanly:

  • messaging.email.templates is request-scoped config sent by the SDK as part of the normal messaging payload.
  • For inbound templates, the server uses url as a universal listener:
    • Pre-model callback: sends full inbound payload and can optionally receive variables.
    • Post-model callback: sends model output/service logs so callers can log outcomes.
  • For outbound templates, the model emits owner_template_name plus owner_template_params inside send_email or send_reply.
  • The SDK does not pre-validate owner_template_params; the server validates that every field named in required is present before dispatch.
  • Email service auto-enable is conditional. For inbound-only template registration payloads, the SDK intentionally keeps services=false and omits service_names so the request acts as mailbox setup instead of a model-driven send workflow. For outbound-capable requests, send_email and send_reply are auto-enabled, so you do not need to list them manually in default_service.

Inbound runtime execution order (after setup):

  1. Resend delivers email.received webhook with data.email_id.

  2. Server fetches full email content by email_id and parses inbound fields.

  3. Mailbox ownership is validated against configured mailbox identity.

  4. Optional inbound template URL callback receives inbound_email_received (full inbound payload); it may return dynamic variables.

  5. Worker runs with persisted mailbox instruction and template context.

  6. Runtime applies routing preference:

    • standalone: true prefers send_email (new message).
    • standalone: false or omitted prefers send_reply (threaded response).
  7. If URL exists, a second callback is emitted with inbound_email_model_response (model response, service calls, resend logs).

Integration guidance for callers:

  • Treat inbound-only template requests as asynchronous configuration writes, not immediate reply jobs.
  • Use mailbox status endpoints or your own callback URL to observe downstream inbound processing results.
  • Callback URL does not require required fields; it can be logging-only.
  • If you need immediate synchronous output in the same request, include outbound-capable work (for example an outbound template and a concrete task), not inbound setup-only config.

How to tap inbound runtime from your app (concrete patterns):

  1. Push pattern (recommended when you need immediate visibility)
  • Configure inbound template url.
  • Rooster POSTs two events to your endpoint during processing:
    • inbound_email_received (pre-model, optionally return parameters)
    • inbound_email_model_response (post-model, logging payload)
  • Callback delivery retries with exponential backoff on transient failures (429 and 5xx).
  • Callback payload and headers include an idempotency key (idempotency_key and X-Idempotency-Key) for safe dedupe.
  • Your endpoint can log both events and only return parameters when needed.
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/email/review-context")
async def review_context(request: Request):
    payload = await request.json()
    event_type = payload.get("event_type")

    # Correlate all runtime callbacks
    print({"event": event_type, "payload": payload})
    print({"idempotency_key": payload.get("idempotency_key")})

    # Return variables only during pre-model event when needed
    if event_type == "inbound_email_received":
        return {
            "parameters": {
                "ticket_id": "rvw-123",
                "review_url": "https://portal.example.com/reviews/rvw-123"
            }
        }

    return {"ok": True}
  1. Pull pattern (when you do not provide callback URLs)
  • Poll the hosted mailbox status surface.
  • Scan recent_logs for callback and completion/failure events.
import time
import requests

BASE = "https://weirdpablo--rooster-api.modal.run"

def wait_for_inbound_result(timeout_seconds=120):
    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        response = requests.get(f"{BASE}/email/api/email-status", timeout=10)
        response.raise_for_status()
        status = response.json().get("status", {})
        logs = status.get("recent_logs", [])

        for entry in reversed(logs):
            event_type = entry.get("event_type")
            if event_type == "inbound_listener_callback":
                callback = entry.get("payload", {})
                print({
                    "listener_event": callback.get("event_type"),
                    "url": callback.get("url"),
                    "success": callback.get("success"),
                })
            if event_type == "inbound_email_processed":
                return {"success": True, "entry": entry}
            if event_type == "inbound_email_error":
                return {"success": False, "entry": entry}

        time.sleep(2)

    return {"success": False, "reason": "timeout"}

Runtime ownership summary:

  • Resend triggers Rooster webhook endpoints.
  • Rooster executes inbound processing asynchronously.
  • SDK setup calls do not receive runtime callbacks automatically unless you configure inbound template url.
  • Without callback URLs, polling is the supported way to observe completion.

Resend Template Integration

The email service integrates with Resend templates for both inbound callback fetching and outbound template rendering.

Automatic Template Creation with shape

You do not need to manually create templates in the Resend dashboard or via a separate API call. When you supply a shape key inside a template config entry, the SDK creates the template in Resend automatically on first use and caches the returned ID — subsequent requests reuse the cached ID and skip creation.

response = client.model.request(
    "Process the approval and send a templated reply",
    messaging={
        "email": {
            "address": "approver@resident.tooig.com",
            "name": "Approver Bot",
            "templates": [{
                    "type": "inbound",
                    "name": "approval_inbound_reply",
                    "required": ["decision", "reviewer_name", "approval_code"],
                    "url": "https://api.example.com/email/approval-context",
                    "standalone": false,
                    "shape": {
                        "subject": "Re: Approval {{approval_code}}",
                        "html": """
                            <h1>Approval Status</h1>
                            <p>Decision: <strong>{{decision}}</strong></p>
                            <p>Reviewer: {{reviewer_name}}</p>
                        """
                    }
                }, {
                    "type": "outbound",
                    "name": "approval_reply",
                    "recipients": ["board@resident.tooig.com"],
                    "required": ["decision", "reviewer_name", "approval_code"],
                    "shape": {
                        "subject": "Your request: {{decision}}",
                        "html": """
                            <h1>Request Status</h1>
                            <p>Your request has been <strong>{{decision}}</strong>.</p>
                            <p>Reviewer: {{reviewer_name}}</p>
                            <p>Approval Code: {{approval_code}}</p>
                        """
                    }
                }]
        }
    }
)

How it works:

  1. On first call, the server persists the template entry including shape to the mailbox config.
  2. provision_template_shapes finds inbound/outbound templates with shape but no template_id.
  3. For each such template, the server calls POST /templates on the Resend API.
  4. The returned Resend template_id is written back into the persisted mailbox config.
  5. All subsequent calls for the same mailbox find template_id already set and skip creation entirely.

shape keys:

Key Required Description
subject Yes Email subject; may use {{handlebars}} variables
html Yes HTML email body; may use {{handlebars}} variables
text No Plain-text fallback
from No Override the sender address for this template
name No Internal Resend display name for the template

Using a Pre-existing Template ID

If you have already created a template in Resend (via the dashboard or previously provisioned out-of-band), pass template_id directly and omit shape:

"templates": [{
    "type": "outbound",
    "name": "approval_reply",
    "template_id": "tmp_1a2b3c4d5e6f7g8h",   # existing Resend template ID
    "required": ["decision", "reviewer_name", "approval_code"],
    "recipients": ["board@resident.tooig.com"]
}]

If both template_id and shape are present, template_id takes precedence and no creation request is made.

Using Templates in the SDK

When the model calls send_reply() or send_email() with a template:

{
    "send_reply": {
        "to": "requestor@example.com",
        "subject": "Your approval status",
        "body": "See attached template rendering.",
        "owner_template_name": "approval_reply",
        "owner_template_params": {
            "decision": "approved",
            "reviewer_name": "Alice Chen",
            "approval_code": "APR-2024-001"
        }
    }
}

The server validates that all required fields are present, then passes them to Resend as variables, which renders the template and sends the final HTML email.

Template Variable Constraints (Resend)

  • Variable names: ASCII letters, numbers, underscores only; max 50 characters
  • Variable values: String max 2000 characters; number max 2^53 - 1
  • Reserved names (cannot be used): FIRST_NAME, LAST_NAME, EMAIL, UNSUBSCRIBE_URL

Hosted inbound webhook notes (Modal):

  • The inbound endpoint (EMAIL_WEBHOOK_URL) receives Resend webhook events and validates Svix headers (Svix-Id, Svix-Timestamp, Svix-Signature).
  • Webhook processing is the execution boundary for inbound automation. Mailbox setup requests only register state; they do not execute response sends on registration alone.
  • Developer callback URLs are optional. If an inbound template url exists, Rooster calls it during processing and the caller can treat that callback hit as a push visibility signal.
  • Without callback URLs, use polling against mailbox status/log surfaces to observe runtime completion.
  • For type == "email.received", the webhook requires data.email_id and queues async processing.
  • email_id is the Resend receiving message identifier, not the RFC Message-ID header and not the mailbox address.
    • The mailbox address (e.g., leni@resident.tooig.com) is used as the inbox identity (to) and ownership target.
    • The email_id is used to fetch the full email body and attachments via Resend's receiving API.
  • If email_id is missing, the endpoint returns {"success": false, "message": "Missing email_id"}.
  • If email_id is present, the endpoint may return queued success even if downstream fetch later fails; inspect email status logs for post-queue outcomes.
  • The queue response includes "email_id" (and backward-compat alias "message_id") so callers can track the inbound message.

Template optionality:

  • If no inbound templates are configured, inbound processing runs normally with no ownership-template callback section.
  • If inbound templates are configured, the processor attempts ownership verification, optionally fetches URL parameters (when url exists), and injects template context into the model input.
  • Inbound templates can drive response routing with standalone: false/unset prefers send_reply, while true prefers send_email.
  • Outbound template rendering remains optional and only applies when the model emits owner_template_name plus owner_template_params.

Mailbox address validation:

  • All mailbox addresses in messaging.email.address must belong to the Resend-configured domain (default: resident.tooig.com).
  • Domain mismatch returns an error; configure all sender addresses within the verified Resend domain.

Live Modal webhook contract test:

RUN_LIVE_WEBHOOK_TESTS=1 ./.venv/bin/pytest tests/test_email_webhook_live_contract.py -q

This test signs real webhook payloads with RESEND_WEBHOOK_SECRET and validates hosted responses for:

  • non-email.received events
  • email.received without email_id
  • email.received with a placeholder email_id (queue contract only)

For streaming requests, messaging-related transport updates arrive as service_response events. The final plain-text answer still remains in the normal result event.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    for event in client.model.request(
        "Send the status email and stream delivery updates.",
        stream=True,
        messaging={"email": {"address": "billing-bot@resident.tooig.com"}},
    ):
        if event["type"] == "service_response":
            print(event["data"])

If you want stdout to reflect everything the SDK yields, handle all visible event types yourself. The SDK yields events immediately; flush=True is the caller-side print behavior, not an internal SDK buffer-flush feature.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    for event in client.model.request(
        "Draft the reply, send it, and surface the delivery details.",
        stream=True,
        messaging={
            "email": {
                "email": "reviews@example.com",
                "name": "Review Desk",
                "templates": [
                    {
                        "type": "outbound",
                        "name": "review_reply",
                        "required": ["decision", "reviewer_name"],
                        "recipients": ["ops@example.com"],
                    }
                ],
            }
        },
    ):
        event_type = event["type"]

        if event_type == "model_delta":
            print(event["data"].get("text", ""), end="", flush=True)
        elif event_type == "service_call":
            print(f"\n[SERVICE_CALL] {event['data']}", flush=True)
        elif event_type == "service_response":
            print(f"\n[SERVICE_RESPONSE] {event['data']}", flush=True)
        elif event_type == "result":
            print("\n[RESULT]", flush=True)
            print(event["data"], flush=True)

Typical streaming shape for an email transport run:

{"type": "accepted", "data": {...}}
{"type": "model_delta", "data": {"text": "I drafted the reply..."}}
{"type": "model_delta", "data": {"text": "\n> Sending reply\n", "progress": True, "synthetic": True}}
{"type": "service_call", "data": {"service_name": "send_reply", "client_managed": False}}
{"type": "service_response", "data": {
    "service_name": "send_reply",
    "success": True,
    "summary": {"status": "sent"},
    "result": {"message_id": "email_123", "delivery": {"status": "sent"}}
}}
{"type": "result", "data": {
    "final_response": "Reply sent.",
    "iterations": 1,
    "usage": {...},
    "thinking": [],
    "service_calls": [...],
    "service_responses": [...]
}}

14 — Persist and reuse a session

Set cache=True to persist the task session and receive a reusable session_id. Pass that session_id back into later requests to continue the same task memory.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    first = client.model.request(
        "Start a running research notebook for crude oil.",
        cache=True,
    )
    session_id = first["session_id"]

    second = client.model.request(
        "Continue from the last note and add today's macro drivers.",
        cache=True,
        session_id=session_id,
    )
    print(second["final_response"])

If cache=False, the task is treated as one-shot and no session_id is exposed.


15 — Opt into built-in services

Built-in services are off by default.

  • default_service=False: no built-in services
  • default_service=True: all built-in services
  • default_service=[...]: only the named built-in services
from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Research OPEC headlines and summarise the impact.",
        default_service=["search_news", "search_web"],
    )
    print(response["final_response"])

16 — Use one include_service surface for custom services

include_service is the only custom-service surface you need.

  • If you pass a local schema.py path or a directory containing schema.py, the SDK loads it locally, exposes its schema to the model, executes the emitted service call in your process, and resumes the run through the callback protocol.
  • Buffered requests still auto-execute local callback services in the caller process.
  • Streaming requests do not auto-execute local callback services. They pause with an awaiting_client_services event so your code can decide how to execute the local work and when to resume.
  • If you pass a shorthand string such as a service-manager name, service name, or local service directory name, the SDK scans local schema.py files and prefers matches under services/ directories.
  • If you pass a service manager class or instance that comes from a schema module, the SDK resolves that module automatically.
  • If you pass an unresolved path string, the SDK passes it through to the API server as a server-hosted schema.py path.
  • If a local service name collides with an existing server-side service name, the request fails early with a clear error instead of running the wrong service.

This keeps the public surface small while still supporting both local and server-managed services.

Local callback example:

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Use my local weather service and compare it with today's oil move.",
        default_service=["search_web"],
        include_service=["./services/weather/schema.py"],
    )
    print(response["final_response"])

Shorthand discovery example:

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Use my local weather service.",
        include_service=["WeatherServiceManager"],
    )
    print(response["final_response"])

Direct manager reference example:

from nineth import NinethClient
from myapp.schema import WeatherServiceManager

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Use my local weather service.",
        include_service=[WeatherServiceManager],
    )
    print(response["final_response"])

Manual streaming callback example:

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    pending_process_id = None

    for event in client.model.request(
        "What is the weather like right now in sf?",
        include_service=["./regulator/schema.py"],
        stream=True,
    ):
        if event["type"] == "model_delta":
            print(event["data"].get("text", ""), end="", flush=True)
        elif event["type"] == "awaiting_client_services":
            pending_process_id = event["process_id"]
            pending_calls = event["data"]["pending_client_calls"]

    if pending_process_id:
        resume = client.model.request(
            "Resume after local weather call.",
            stream=True,
            session_id=pending_process_id,
            client_service_results=[
                {
                    "call_id": pending_calls[0]["call_id"],
                    "service_name": "get_weather",
                    "success": True,
                    "result": {"location": "San Francisco", "forecast": "sunny"},
                }
            ],
        )
        for event in resume:
            if event["type"] == "model_delta":
                print(event["data"].get("text", ""), end="", flush=True)

Server-hosted path example:

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Use the custom weather service and compare it with market risk sentiment.",
        default_service=["search_web"],
        include_service=["/srv/app/services/weather/schema.py"],
    )
    print(response["final_response"])

Minimal local schema.py shape:

from typing import Any, Dict


class WeatherServiceManager:
    def get_weather(self, location: str) -> Dict[str, Any]:
        return {"location": location, "forecast": "sunny"}


weather_services = {
    "get_weather": {
        "name": "get_weather",
        "description": "Return a tiny weather forecast.",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {"type": "string"}
            },
            "required": ["location"],
            "additionalProperties": False,
        },
    }
}


weather_implementations = {
    "get_weather": lambda manager: manager.get_weather,
}

17 — Enable verbose telemetry

Set verbose=True to keep the worker's structured telemetry in the buffered response or streamed events. The legacy debug=True alias still works.

from nineth import NinethClient

with NinethClient(default_model="1984-m3-0317") as client:
    response = client.model.request(
        "Research market risk and show the internal trace.",
        default_service=["search_news"],
        verbose=True,
    )
    print(response.get("events", []))

Response shape

Buffered (stream=False)

{
    "final_response": "Bitcoin is trading near...",
    "iterations": 2,
    "compute": 500,
    "usage": {"prompt_tokens": 412, "completion_tokens": 88, "total_tokens": 500},
    "thinking": [],          # only populated when show_reasoning=True
    "service_calls": [...],
    "service_responses": [...],
    "events": [...],
}

Only final_response and iterations are guaranteed to be present on every response. When response_format="json" and the final response is valid JSON, final_response is the parsed object and raw_response preserves the original string. compute is only included when requested and matches the provider total token count. When messaging is active, relevant service_responses entries may include a raw result payload with transport identifiers or delivery state.

For template-backed email sends, the buffered service_responses shape may include the same transport payload while the actual template variables remain part of the executed service call parameters:

{
    "final_response": "Reply sent.",
    "iterations": 1,
    "service_calls": [
        {
            "service_name": "send_reply",
            "params": {
                "owner_template_name": "review_reply",
                "owner_template_params": {
                    "decision": "approved",
                    "reviewer_name": "A. Chen"
                }
            }
        }
    ],
    "service_responses": [
        {
            "service_name": "send_reply",
            "success": True,
            "summary": {"status": "sent"},
            "result": {
                "message_id": "email_123",
                "delivery": {"status": "sent"}
            }
        }
    ]
}

Streaming (stream=True)

Each loop iteration yields a dict:

# Text arriving live
{"type": "model_delta", "data": {"text": "Bitcoin is trading..."}}

# service calls the model made
{"type": "service_call",     "data": {"service_name": "search_web", "params": {...}}}
{"type": "service_response", "data": {"service_name": "search_web", "success": True, "summary": {...}}}

# messaging-enabled service updates can also include raw transport state
{"type": "service_response", "data": {"service_name": "send_email", "success": True, "summary": {...}, "result": {"message_id": "..."}}}

# Final summary — always the last event
{"type": "result", "data": {"final_response": "...", "iterations": 2, "compute": 500}}

Error handling

from nineth import NinethClient, NinethAPIError

with NinethClient(default_model="1984-m3-0317") as client:
    try:
        response = client.model.request("Analyse ETH.")
    except NinethAPIError as exc:
        print("API error:", exc)
    except ValueError as exc:
        print("Configuration error:", exc)

SDK-visible error messages scrub transport or provider product names to tooig and strip URLs before raising NinethAPIError.


Authentication

Set NINETH_API_KEY in your environment or pass api_key= to the client constructor. That key can be any one key registered by the server's NINETH_API_KEYS registry. The health check endpoint does not require a key.

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nineth-0.6.7.tar.gz (39.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nineth-0.6.7-py3-none-any.whl (29.7 kB view details)

Uploaded Python 3

File details

Details for the file nineth-0.6.7.tar.gz.

File metadata

  • Download URL: nineth-0.6.7.tar.gz
  • Upload date:
  • Size: 39.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nineth-0.6.7.tar.gz
Algorithm Hash digest
SHA256 f1f8aae49fc8bd408b365e8de3aef0f674de80d0c26a6fbc503beb0baf0ce400
MD5 7423ed75813cce4905315ea7e49c0c25
BLAKE2b-256 97ce881d9c44817c6f1585e116932da1438e45f1c6104d8c9fcb578377564091

See more details on using hashes here.

File details

Details for the file nineth-0.6.7-py3-none-any.whl.

File metadata

  • Download URL: nineth-0.6.7-py3-none-any.whl
  • Upload date:
  • Size: 29.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nineth-0.6.7-py3-none-any.whl
Algorithm Hash digest
SHA256 e2e5f01498ddb37d47c6cd47bc1889e9532ebad8b67325971c6d0b6bb0c93f5c
MD5 f024e2260828bde483df60de118eeaa2
BLAKE2b-256 62c587d0b20a85b320abf55dcb0a6a4e43f252fa367d36336b2d854785cf7e76

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page