Metadata-Version: 2.4
Name: simoai
Version: 0.3.0
Summary: Python SDK for Simo: typed questions about a situation, answered with calibrated probabilities
Author: Temprl
License-Expression: Apache-2.0
Project-URL: Homepage, https://temprl.org
Project-URL: API, https://api.temprl.org
Keywords: simo,llm,calibrated,classification,extraction,agents
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: image
Requires-Dist: Pillow>=9; extra == "image"
Provides-Extra: video
Requires-Dist: Pillow>=9; extra == "video"
Requires-Dist: opencv-python; extra == "video"
Dynamic: license-file

# simoai: the Simo Python SDK

Simo answers typed questions about a situation (text, images or video) with a calibrated probability for every
possible answer. Where acting needs a value, it also fills it in. One request reads the situation once and answers
every question you ask about it.

```python
from simoai import Client, Noul

c = Client(api_key="simo_sk_...")      # or set $SIMO_API_KEY
r = c.ask("The customer says the parcel arrived broken and wants their money back.",
          {"refund": Noul("The customer asks for a refund")})
r["refund"].noul        # 0.99: the probability that the statement is true
r.cost_usd              # what the request cost
```

- [Setup](#setup)
- [Models](#models)
- [Question types](#question-types)
- [Images](#images)
- [Video](#video)
- [Reading the response](#reading-the-response)
- [Request options](#request-options)
- [Read first, then decide: two_pass and derive](#read-first-then-decide-two_pass-and-derive)
- [Task: the same questions over many documents](#task-the-same-questions-over-many-documents)
- [Agent: the next tool call, in a loop](#agent-the-next-tool-call-in-a-loop)
- [Async](#async)
- [Cost](#cost)
- [Service information](#service-information)
- [Errors](#errors)
- [Command line](#command-line)
- [Without the SDK: plain HTTP](#without-the-sdk-plain-http)
- [Patterns that work](#patterns-that-work)

## Setup

```bash
pip install simoai
pip install "simoai[image]"      # for image() / video()   (adds Pillow)
pip install "simoai[video]"      # only for video_file()   (adds Pillow and OpenCV)
```

Python 3.10 or newer. The SDK itself uses only the standard library.

Create an API key on the dashboard (keys look like `simo_sk_...`), then:

```python
from simoai import Client
c = Client(api_key="simo_sk_...")      # talks to https://api.temprl.org
```

Keep the key out of your source: set `SIMO_API_KEY` and call `Client()`.

Check that the key works before building on it:

```python
from simoai import Client, Noul
with Client() as c:                                  # reads $SIMO_API_KEY; closes the connection on exit
    r = c.ask("The parcel arrived broken.", {"damaged": Noul("The goods are damaged")})
    print(r["damaged"].noul, r.cost_usd, r.balance_usd)
```

A wrong or revoked key raises `AuthError`, and an empty balance raises `InsufficientBalance` (see [Errors](#errors)).
`simoai.__version__` is the installed SDK version.

| Argument | Default | Meaning |
|---|---|---|
| `base_url` | `$SIMO_BASE_URL`, else `https://api.temprl.org` | the API. Use `https://`: over plain `http://` to a remote host the key travels in clear text, and the SDK warns |
| `api_key` | `$SIMO_API_KEY` | sent as `Authorization: Bearer …` |
| `model` | `"simo-1-pro"` | default model for every call |
| `timeout` | `120` | seconds to wait for an answer |
| `retries` | `3` | extra attempts on 429 / 502 / 503 / 504 and connection errors, honoring `Retry-After`. Those failures are not charged |

One `Client` keeps one connection open per thread and is thread-safe, so create it once and reuse it. Use it as a
context manager, or call `c.close()`, to close the connection when you are done.

## Models

| Model | API id | Status |
|---|---|---|
| **Simo-1 Pro** | `simo-1-pro` | available. Hard calls: when it is torn, it deliberates before answering |
| **Simo-1** | `simo-1` | coming soon (`c.models()` says which are available) |

On a confident decision Pro answers at full speed. It only spends extra time on the decisions that are genuinely
uncertain. The default model is `simo-1-pro`; pass `model=` to a client or a call to choose another once it opens.

## Question types

Every question has an id (the key in `questions`) and comes back as one answer under that id.

### Decisions: a probability for every answer

```python
from simoai import Noul, Choice, Score

questions = {
    # yes / no -> answer.noul = P(true)
    "done": Noul("The task is finished"),
    "risky": Noul("The next action is risky", criteria={"true": "it deletes or pays", "false": "read-only"}),

    # one of several options -> answer.choice, answer.probabilities
    "team": Choice("Which team should handle this ticket?",
                   {"billing": "refunds and payments", "shipping": "delivery and tracking",
                    "tech": "website and app problems"}),

    # a level on an ordered scale -> answer.score (expected level, 0-based)
    "mood": Score("How upset is the customer?", ["calm", "annoyed", "angry", "furious"]),
}
```

- **Choice** takes 2 to 256 options. The keys are what you get back, and the descriptions help the model.
- **Score** takes any number of levels. `score` is the expected level (e.g. `2.4` on a 0–3 scale), and
  `probabilities` holds one entry per level.

### Reads: a value from the input (preview)

```python
from simoai import Fill, Extract, Locate

questions = {
    "amount":  Fill("the amount due, in dollars", kind="number"),          # parsed -> float
    "count":   Fill("how many items are in the order", kind="int"),       # parsed -> int
    "colors":  Fill("the colors mentioned", kind="json_list"),            # parsed -> list of strings
    "code":    Fill("the discount code", pattern=r"[A-Z]{3}-\d{4}"),      # must match the regex
    "summary": Fill("the customer's request in a few words", max_tokens=24),

    "order":   Extract("the order number, exactly as written", max_tokens=8),   # value -> "A-1042"
    "orders":  Extract("every order number mentioned", many=True),              # values -> ["A-1042", "B-2210"]

    "pay_btn": Locate("the Pay button"),           # needs an image, see below
}
```

| Type | Arguments | You get |
|---|---|---|
| `Fill` | `kind`: `text` (default), `int`, `number`, `json_list`; or `pattern` (a regex); `max_tokens` (1–256, default 16) | `value` (text), `parsed` (typed), `valid` |
| `Extract` | `many` (list of values), `max_tokens` (1–512, default 64) | `value`, or `values` with `many=True` |
| `Locate` | `image` (which image, 1-based), `scale` (`"rel1000"` or `"pixels"`), `max_tokens` | `coordinates` `[x1, y1, x2, y2]`, `center` `[x, y]`, `image_size` |

- Output is constrained to its type: an `int` is always an integer, and a list always parses.
- `valid` tells you whether the value satisfied its type.
- When `max_tokens` cuts a value short, `valid` can be `False`.

### Arguments for a choice

A `Choice` can generate arguments for the option the model picked, in the same request. Only the chosen option's
arguments are produced:

```python
"next": Choice("What should the agent do next?",
               {"click": "click an element", "type": "type text", "stop": "the task is done"},
               args={"click": {"target": Extract("the element to click")},
                     "type":  {"text": Fill("the text to type", max_tokens=32)}})

r["next"].choice        # "click"
r["next"].arg_values    # {"target": "Sign up"}
```

### Plain dictionaries

Anywhere a question object is accepted, a dict in the wire format works too, which is handy for JSON files:

```python
{"type": "noul", "instructions": "The task is finished"}
{"type": "extract", "instructions": "every SKU", "kind": "text", "many": True, "list_format": "json"}
```

`list_format: "json"` is a dict-only option. It makes a `many` extract come back from a JSON list instead of one
item per line, and the result is the same `values` list.

## Images

Pass images in `media` and refer to them in the state as `<image:1>`, `<image:2>`, … in order, each one once:

```python
from simoai import Client, Noul, Choice, Extract, Locate, image

c = Client(api_key="simo_sk_...")
r = c.ask(
    state={"task": "Sign up for the newsletter", "screen": "<image:1>"},
    media=[image("screenshot.png")],
    questions={
        "done":   Noul("The sign-up is complete"),
        "next":   Choice("What should the agent do next?",
                         {"click": "click something", "scroll": "scroll down", "stop": "stop"},
                         args={"click": {"target": Extract("the label of the element to click")}}),
        "button": Locate("the Sign up button"),
    })

r["done"].noul                 # 0.04
r["next"].choice               # "click"
r["next"].arg_values           # {"target": "Sign up"}
r["button"].center             # [912.0, 404.5]  <- pixels of YOUR screenshot.png, ready to click
r["button"].coordinates        # [850.0, 388.0, 974.0, 421.0]
```

`image()` accepts:

- a file path
- an `http(s)` URL (downloaded by the SDK)
- a `data:` URI
- raw `bytes`
- a PIL image

```python
image("photo.jpg")
image("https://example.com/parcel.png")
image(open("scan.png", "rb").read())
image(pil_image)
```

Several images:

```python
r = c.ask(state="Before: <image:1>\nAfter: <image:2>",
          media=[image("before.png"), image("after.png")],
          questions={"changed": Noul("Something changed between the two pictures"),
                     "where": Locate("what changed", image=2)})          # image=2: locate in the second picture
```

About images:

- **Size.** Send images at their natural size. `image()` pads them on white to a shape the model reads without
  resampling, so small text stays legible. Locate answers are mapped back to your original pixels automatically.
- **Not found.** `Locate` returns `coordinates=None` and `center=None` when the thing is not visible;
  `image_size` is still set. Check `r["button"].center is not None` before clicking.
- **Placeholder order.** The SDK warns when the placeholders don't match `media` (wrong order, missing, or extra),
  and the server rejects such requests with `InvalidRequest`.
- **Limits.** The service accepts a limited number of pictures per request, video frames included (16 on the
  standard setup); going over raises `InvalidRequest` naming the limit. A request body over 25 MB is rejected
  (`InvalidRequest`, code `payload_too_large`): images are sent as PNG, so a few large screenshots add up. The SDK
  checks the 25 MB limit before sending, so nothing is uploaded for a request that cannot be accepted.

## Video

```python
from simoai import video, video_file

# from a file: sampled at 1 frame per second, at most 32 frames (needs opencv-python)
clip = video_file("entrance.mp4", fps=1.0)

# or from frames you already have, with the rate they were sampled at
clip = video([frame1, frame2, frame3], fps=2.0)

r = c.ask(state={"camera": "Warehouse door", "clip": "<video:1>"}, media=[clip],
          questions={"entered": Noul("A person enters the restricted area"),
                     "count": Fill("how many people appear", kind="int")})
```

The model sees the frames with their timestamps. The service keeps at most 16 frames per video, spread evenly. Ask
"did it happen?", not "at which exact frame?".

## Reading the response

```python
r = c.ask(state, questions)

r["team"]                 # one answer
r["team"].choice          # fields by name; r["team"].raw is the JSON object
r.values                  # {"team": "billing", "amount": 89.0, "done": True, ...}   every answer as a plain value
r.certainty               # {"team": 0.94, "amount": 0.99, "done": 0.97, ...}       how sure, per answer
r.timing                  # Timing(client_ms=..., server_ms=...): where the time went
r.usage                   # tokens used: input_tokens, output_tokens, cost
r.cost_usd                # what this request cost, in US dollars
r.balance_usd             # your balance after it
r.model                   # "simo-1-pro"
r.request_id              # quote it to support
```

Every answer has `.result` (its value in the natural type) and `.certainty` (in [0, 1]):

| Type | Fields | `.result` | `.certainty` |
|---|---|---|---|
| noul | `noul` | `noul >= 0.5` | `max(noul, 1 - noul)` |
| choice | `choice`, `probabilities`, `confidence`, `args` | the option | its probability |
| score | `score`, `probabilities`, `legend`, `confidence` | the expected level | `confidence` |
| fill | `value`, `parsed`, `valid`, `confidence`, `check`, `tokens` | `parsed`, else `value` | `confidence` |
| extract | `value` / `values`, `valid`, `confidence`, `check`, `tokens` | `values` or `value` | `confidence` |
| locate | `coordinates`, `center`, `image_size`, `valid`, `confidence` | `center` | `confidence` |

To group answers by type, use `r.nouls`, `r.choices`, `r.scores`, `r.fills` and `r.extracts`.

**Calibrated means usable as a threshold.** When a decision says 0.8, it is right about 80% of the time, so you can
act on it directly:

```python
if r["refund"].noul > 0.95:   auto_refund()
elif r["refund"].noul > 0.6:  ask_a_person()
```

`confidence` on reads (Fill / Extract / Locate) reflects how sure the model was while writing. It is useful, but it
is not calibrated. For a calibrated check of a value, use `self_check=True` (see below).

## Request options

```python
c.ask(state, questions,
      media=[...],            # images / videos for <image:N> / <video:N>
      model="simo-1-pro",     # override the client's model
      self_check=True,        # each Fill / Extract gets answer.check: calibrated P(the value is right)
      debias=3,               # read each Choice under 3 option orders and average (steadier, slower)
      two_pass=True,          # reads first, then decisions that see the values read (below)
      derive={...},           # exact arithmetic over values read (below)
      cache=False,            # skip the exact-request response cache
      request_id="ticket-17") # sent as x-request-id; quote it to support
```

- **`self_check`** costs one extra decision per read, and returns a calibrated `check` you can threshold:
  `if r["amount"].check < 0.9: review()`.
- **`debias`** helps when option order might sway a choice (long, similar options).
- **`ask_json(request_dict, **options)`** sends a request already in wire format, e.g. loaded from a file.

## Read first, then decide: `two_pass` and `derive`

When a decision depends on a value in the input, have Simo read the value first and then decide with it in front of
it. Do arithmetic in code, never in the model:

```python
from simoai import Client, Extract, Fill, Noul, days_between, difference, sum_of

r = c.ask(
    {"claim": claim_text, "policy": policy_text},
    {"accident": Extract("the date of the accident, exactly as written", max_tokens=12),
     "filed":    Extract("the date the claim was filed, exactly as written", max_tokens=12),
     "amount":   Fill("the amount claimed, in dollars", kind="number"),
     "late":     Noul("The claim was filed more than 30 days after the accident")},
    derive={"days": days_between("accident", "filed", "days between the accident and the claim")})

r.values["days"]     # 34: computed exactly by the service from the two dates read
r.values["late"]     # True, decided with "days: 34" in front of the model
r.facts              # the facts the decisions were shown
```

| Helper | Meaning |
|---|---|
| `days_between(a, b)` | days from date `a` to date `b` ("2026-09-28", "September 28, 2026", "Sep 28") |
| `difference(a, b)` | `b - a` |
| `sum_of(a, b, ...)` | the sum |

`derive` turns on `two_pass` automatically. A derived value whose inputs can't be read is simply left out of the
answers.

## Task: the same questions over many documents

`Task` bundles a set of questions with the rule for when the answers are safe to act on without a person:

```python
from simoai import Task, Choice, Extract, Fill, Noul, days_between

triage = Task(
    questions={"policy_no": Extract("the policy number"),
               "amount":    Fill("the amount claimed, in dollars", kind="number"),
               "covered":   Noul("The loss is covered by the policy"),
               "route":     Choice("Where does this claim go?",
                                   {"deny": "not covered", "adjuster": "needs an adjuster",
                                    "fast_track": "small and clear"})},
    act_at=0.9,                                   # every decision at least this sure, or a person looks
    read_at=0.8,                                  # every read at least this confident
    check=lambda v: ["fast-track over $5,000"] if v["route"] == "fast_track" and v["amount"] > 5000 else [])

res = triage.run(c, {"policy": POLICY, "claim": claim_text})
if res.ok:
    route(res["route"])
else:
    send_to_person(res.review)                    # e.g. ["route='adjuster' p=0.71"]

results = triage.run_many(c, [claim1, claim2, claim3], workers=4)   # in input order
```

## Agent: the next tool call, in a loop

```python
from simoai import Agent, Extract, Fill, Noul, History

agent = Agent(c,
    actions={"lookup_order": "look up an order by its number",
             "issue_refund": "refund an order",
             "ask_customer": "ask the customer a question",
             "close_ticket": "close the ticket"},
    args={"lookup_order": {"order_id": Extract("the order number to use, exactly as written", max_tokens=8)},
          "issue_refund": {"order_id": Extract("the order number"),
                           "amount": Fill("the refund amount", kind="number")},
          "ask_customer": {"question": Fill("the question to ask, one short sentence", max_tokens=32)}},
    question="Following the policy and what has happened so far, what is the agent's next action?",
    act_at=0.8,                                    # below this, stop and hand over
    extra={"angry": Noul("The customer is angry")})

d = agent.decide({"policy": POLICY, "ticket": ticket_text, "history": "(nothing yet)"})
d.action, d.args, d.p        # "lookup_order", {"order_id": "A-1042"}, 0.97
d.call                       # "lookup_order(order_id='A-1042')"
d.extra                      # {"angry": False}
```

Run it in a closed loop against an environment that has `state()`, `act(action, args)` and `done`:

```python
class Env:
    def __init__(self):
        self.history = History()
        self.done = False

    def state(self):                       # may also return (state, media) for screenshots
        return {"policy": POLICY, "ticket": ticket_text, "history": str(self.history)}

    def act(self, action, args):
        result = TOOLS[action](**args)
        self.history.add(f"{action}({args})", result)
        self.done = action == "close_ticket"
        return result

run = agent.run(Env(), max_steps=20)
run.finished, run.reason, run.calls      # True, "done", ["lookup_order(...)", "issue_refund(...)", ...]
```

`run.reason` is `"done"`, `"uncertain"` (a step fell below `act_at`), `"max_steps"` or `"stopped"`.

## Async

```python
import asyncio
from simoai import AsyncClient, Noul

async def main():
    async with AsyncClient(api_key="simo_sk_...", max_concurrency=8) as c:
        results = await asyncio.gather(*(c.ask(t, {"spam": Noul("This is spam")}) for t in texts))

asyncio.run(main())
```

`AsyncClient` has the same methods as `Client` (`ask`, `ask_json`, `models`, `health`), as coroutines.

## Cost

You pay for input tokens only; output is free (the rates are on the dashboard). Reading the situation is the cost,
and questions are nearly free,
so ask everything about one input in one request. Every response says what it cost (`r.cost_usd`, `r.usage`) and
what is left (`r.balance_usd`). An exact repeat of a request is answered from a cache (`r.timing.response_cache`);
`cache=False` bypasses it.

## Service information

```python
c.models()          # [{"id": "simo-1-pro", "available": True, ...}, {"id": "simo-1", "available": False, ...}]
c.health()          # {"ok": True, ...}   (no key needed)
```

## Errors

Every failure raises a `SimoError` subclass:

| Exception | When | What to do |
|---|---|---|
| `AuthError` (401) | missing, wrong or revoked API key | check `api_key` / `$SIMO_API_KEY` |
| `InsufficientBalance` (402) | no balance left | add credit; the request was not charged |
| `PermissionDenied` (403) | account suspended, or the model is not open to you (`exc.code`) | contact support, or use an available model |
| `InvalidRequest` (400/413/422) | bad JSON or question, media not matching the state, over 25 MB | read `exc.detail` and `exc.code`; images count at their sent size |
| `ModelNotFound` (404 or `unknown_model`) | unknown `model` | see `c.models()` |
| `QueueFull` (429) | too many requests | retried automatically (`retries`); then back off |
| `Unavailable` (502/503/504) | the service or its model is briefly unavailable | retried automatically; not charged |
| `ServerError` (other 5xx) | the service failed on this request | retry later; quote `exc.request_id` |
| `ConnectionFailed` | the API is unreachable | check your network |
| `SimoTimeout` | no answer within `timeout` | raise `timeout` for big media or hard questions |

Every error carries `exc.status`, `exc.code` (for example `"insufficient_balance"`), `exc.detail` and `exc.request_id`.
Log the request id with any failure you report to support.

```python
from simoai import SimoError, InvalidRequest
try:
    r = c.ask(state, questions)
except InvalidRequest as e:
    print(e.detail)            # [{"loc": ["body", "questions", "team", "criteria"], "msg": "...", ...}]
except SimoError as e:
    print(e.status, e, e.request_id)
```

## Command line

```bash
export SIMO_API_KEY=simo_sk_...
python -m simoai health
python -m simoai models
python -m simoai ask request.json --full         # request.json: {"state": ..., "questions": {...}}
python -m simoai ask - --self-check < request.json
```

The same commands are installed as `simo` (`simo health`, `simo ask request.json`).

`request.json` uses the wire format:

```json
{"state": "The customer wants their money back for order A-1042.",
 "model": "simo-1-pro",
 "questions": {"refund": {"type": "noul", "instructions": "The customer asks for a refund"},
               "order": {"type": "extract", "instructions": "the order number", "kind": "text", "many": false,
                         "max_tokens": 8}}}
```

## Without the SDK: plain HTTP

The SDK is a thin client over one endpoint, `POST https://api.temprl.org/v1/ask`, so any language can call it:

```bash
curl https://api.temprl.org/v1/ask   -H "Authorization: Bearer $SIMO_API_KEY"   -H "Content-Type: application/json"   -d '{"model": "simo-1-pro",
       "state": "The customer says the parcel arrived broken and wants their money back.",
       "questions": {"refund": {"type": "noul", "instructions": "The customer asks for a refund"}}}'
```

The body is the wire format shown under [Command line](#command-line); request options go in `"options"`, e.g.
`"options": {"self_check": 1, "debias": 3}`. The response carries `model`, `answers`, `usage` and the headers
`x-request-id`, `x-simo-cost-usd`, `x-simo-balance-usd` and `x-simo-latency-ms`. Errors come back as
`{"error": {"code": "...", "message": "..."}}` with the status codes listed under [Errors](#errors).
`GET /v1/models` lists the models and `GET /health` needs no key.

## Patterns that work

- **Ask everything at once.** Ten questions about one screenshot cost little more than one. One request per question
  wastes the read.
- **Gate on probabilities.** Act above a high threshold, escalate in the middle, and refuse or ask a person below it.
  The thresholds mean what they say.
- **Read, then decide.** If a decision depends on a number or date in the input, `Extract` / `Fill` it and let
  `two_pass` / `derive` put it in front of the decision.
- **Put the instructions in the state.** Policies, rules and history belong in the state (a dict works well:
  `{"policy": ..., "ticket": ..., "history": ...}`). The question then stays short and specific.
- **Write option descriptions.** `{"refund": "the customer wants money back"}` is clearer to the model than a bare
  key.
- **Pro only slows down when it is unsure.** On a confident decision it answers at full speed, so it suits both
  loops and the calls where a wrong answer is expensive.

## License

Apache License 2.0. See `LICENSE`.
