mellontoken 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mellontoken-0.1.0/.gitignore +36 -0
- mellontoken-0.1.0/PKG-INFO +103 -0
- mellontoken-0.1.0/README.md +93 -0
- mellontoken-0.1.0/mellontoken/__init__.py +531 -0
- mellontoken-0.1.0/pyproject.toml +18 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
|
|
7
|
+
# Env / secrets — NEVER commit
|
|
8
|
+
.env
|
|
9
|
+
.env.*
|
|
10
|
+
!.env.example
|
|
11
|
+
|
|
12
|
+
# Tooling caches
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
|
|
17
|
+
# Node / frontend
|
|
18
|
+
node_modules/
|
|
19
|
+
dist/
|
|
20
|
+
# TanStack Router generated tree (rebuilt by the vite plugin)
|
|
21
|
+
frontend/src/routeTree.gen.ts
|
|
22
|
+
# OpenAPI-generated client (regenerated on predev/prebuild)
|
|
23
|
+
frontend/src/client/
|
|
24
|
+
|
|
25
|
+
# OS / editor
|
|
26
|
+
.DS_Store
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
|
|
30
|
+
# Harvested org prompts — internal, never committed
|
|
31
|
+
ORG_PROMPTS.md
|
|
32
|
+
|
|
33
|
+
# Customer workload measurements — a client's call volumes and token counts, which
|
|
34
|
+
# are their commercial data rather than ours. `scripts/cost_summary` reads these;
|
|
35
|
+
# the format is documented in its docstring so a file can be rebuilt from a run.
|
|
36
|
+
backend/workloads/
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mellontoken
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the mellontoken gateway: ask(prompt), and it tells you what it cost.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Provides-Extra: schema
|
|
8
|
+
Requires-Dist: pydantic>=2.0; extra == 'schema'
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# mellontoken
|
|
12
|
+
|
|
13
|
+
Client for the mellontoken gateway.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import mellontoken
|
|
17
|
+
|
|
18
|
+
answer = await mellontoken.ask("Summarise this tender in one line.")
|
|
19
|
+
parsed = await mellontoken.ask(document_text, schema=DocExtraction)
|
|
20
|
+
full = await mellontoken.ask_full(document_text, schema=DocExtraction)
|
|
21
|
+
full.cost_usd # "0.00059550"
|
|
22
|
+
full.model # the model that actually served it
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Letting the gateway pick the model
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
answer = await mellontoken.ask(prompt, model="auto")
|
|
29
|
+
answer = await mellontoken.ask(prompt, model="auto", tier="high") # bound the spend
|
|
30
|
+
|
|
31
|
+
full = await mellontoken.ask_full(prompt, model="auto")
|
|
32
|
+
full.route.model_slug # what it picked
|
|
33
|
+
full.route.task # the grid row it read
|
|
34
|
+
full.route.explanation # why, in a paragraph you can argue with
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
One command. `model="auto"` makes the gateway classify the prompt on three axes
|
|
38
|
+
(task, effort, capability), read the resulting grid cell, and serve the answer from
|
|
39
|
+
whatever that cell names — and `ask_full` reports the decision on `Answer.route`, so
|
|
40
|
+
routing is legible after the fact without a second call to ask about it.
|
|
41
|
+
|
|
42
|
+
Those three classifier calls are billed, per call. They cost roughly $0.005 against
|
|
43
|
+
a completion's dollars and land on the ledger as `kind=classifier` rows, so the
|
|
44
|
+
spend is visible rather than hidden. **A service sending the same shape of prompt a
|
|
45
|
+
thousand times should read `full.route.model_slug` once and then name that slug**:
|
|
46
|
+
the decision is a property of the workload, not of each call.
|
|
47
|
+
|
|
48
|
+
`tier=` bounds what the router may spend and outranks the effort classifier, because
|
|
49
|
+
a tier is a statement about your budget rather than a property of the text. Set it
|
|
50
|
+
per call, or once via `MELLONTOKEN_TIER`. It is only read on the `auto` path — with a
|
|
51
|
+
named model it is logged as ignored rather than silently dropped.
|
|
52
|
+
|
|
53
|
+
**A router that resolves nothing is not an error.** `route.model_slug` is None when
|
|
54
|
+
the capability gate rejected every candidate in the cell; `ask` logs the explanation
|
|
55
|
+
and falls back to `MELLONTOKEN_MODEL`, which is what the gateway's own advice is —
|
|
56
|
+
keep your own model. The one case that raises is `MELLONTOKEN_MODEL=auto` as well,
|
|
57
|
+
where there is no own model to keep.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
From the environment:
|
|
62
|
+
|
|
63
|
+
| variable | default |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `MELLONTOKEN_BASE_URL` | `http://localhost:8000/api/v1` |
|
|
66
|
+
| `MELLONTOKEN_API_KEY` | — required |
|
|
67
|
+
| `MELLONTOKEN_MODEL` | `gemini-3.7-flash` (or `auto`) |
|
|
68
|
+
| `MELLONTOKEN_TIER` | — (the effort classifier judges it) |
|
|
69
|
+
| `MELLONTOKEN_MAX_TOKENS` | `32000` |
|
|
70
|
+
| `MELLONTOKEN_TIMEOUT` | `600` |
|
|
71
|
+
|
|
72
|
+
or in code:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
mellontoken.configure(base_url="http://gateway:8000/api/v1", api_key=key, model="gpt-5.6-luna")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
From inside a container the host's gateway is `host.docker.internal`, not `localhost`.
|
|
79
|
+
|
|
80
|
+
## What `ask` does that a bare HTTP call does not
|
|
81
|
+
|
|
82
|
+
- **Sends the schema twice**, as `response_format` *and* as prompt text. The request
|
|
83
|
+
field is only honoured where the provider can enforce it; Gemini accepts a schema
|
|
84
|
+
it will then ignore, and the field names in the prompt are what stop it inventing
|
|
85
|
+
`col_idx` for `col`.
|
|
86
|
+
- **Reads JSON out of a fenced reply**, and wraps a bare list under the schema's
|
|
87
|
+
single array field when that is unambiguous.
|
|
88
|
+
- **Retries the shape, not the wire.** A 401/402/403/404 is final — a key, a budget,
|
|
89
|
+
a permission, a missing model — and raises immediately. A reply that did not parse
|
|
90
|
+
is retried three times.
|
|
91
|
+
- **Logs the cost of every call**, which is the reason to route through the gateway
|
|
92
|
+
at all: `mellon.cost_usd` is the only per-call price any provider path reports.
|
|
93
|
+
- **Resolves `model="auto"` before sending anything.** `auto` is a request for a
|
|
94
|
+
decision, not a catalogue slug, and the completion endpoint would 404 it. Resolved
|
|
95
|
+
once per `ask`, outside the retry loop: a reply that did not parse is a reason to
|
|
96
|
+
ask the same model again, not to pay three classifiers for the same decision again.
|
|
97
|
+
|
|
98
|
+
## Install
|
|
99
|
+
|
|
100
|
+
Built from this directory, which lives in the gateway's own repo so the client and
|
|
101
|
+
the endpoint version together:
|
|
102
|
+
|
|
103
|
+
uv build # -> dist/mellontoken-0.1.0-py3-none-any.whl
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# mellontoken
|
|
2
|
+
|
|
3
|
+
Client for the mellontoken gateway.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import mellontoken
|
|
7
|
+
|
|
8
|
+
answer = await mellontoken.ask("Summarise this tender in one line.")
|
|
9
|
+
parsed = await mellontoken.ask(document_text, schema=DocExtraction)
|
|
10
|
+
full = await mellontoken.ask_full(document_text, schema=DocExtraction)
|
|
11
|
+
full.cost_usd # "0.00059550"
|
|
12
|
+
full.model # the model that actually served it
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Letting the gateway pick the model
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
answer = await mellontoken.ask(prompt, model="auto")
|
|
19
|
+
answer = await mellontoken.ask(prompt, model="auto", tier="high") # bound the spend
|
|
20
|
+
|
|
21
|
+
full = await mellontoken.ask_full(prompt, model="auto")
|
|
22
|
+
full.route.model_slug # what it picked
|
|
23
|
+
full.route.task # the grid row it read
|
|
24
|
+
full.route.explanation # why, in a paragraph you can argue with
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
One command. `model="auto"` makes the gateway classify the prompt on three axes
|
|
28
|
+
(task, effort, capability), read the resulting grid cell, and serve the answer from
|
|
29
|
+
whatever that cell names — and `ask_full` reports the decision on `Answer.route`, so
|
|
30
|
+
routing is legible after the fact without a second call to ask about it.
|
|
31
|
+
|
|
32
|
+
Those three classifier calls are billed, per call. They cost roughly $0.005 against
|
|
33
|
+
a completion's dollars and land on the ledger as `kind=classifier` rows, so the
|
|
34
|
+
spend is visible rather than hidden. **A service sending the same shape of prompt a
|
|
35
|
+
thousand times should read `full.route.model_slug` once and then name that slug**:
|
|
36
|
+
the decision is a property of the workload, not of each call.
|
|
37
|
+
|
|
38
|
+
`tier=` bounds what the router may spend and outranks the effort classifier, because
|
|
39
|
+
a tier is a statement about your budget rather than a property of the text. Set it
|
|
40
|
+
per call, or once via `MELLONTOKEN_TIER`. It is only read on the `auto` path — with a
|
|
41
|
+
named model it is logged as ignored rather than silently dropped.
|
|
42
|
+
|
|
43
|
+
**A router that resolves nothing is not an error.** `route.model_slug` is None when
|
|
44
|
+
the capability gate rejected every candidate in the cell; `ask` logs the explanation
|
|
45
|
+
and falls back to `MELLONTOKEN_MODEL`, which is what the gateway's own advice is —
|
|
46
|
+
keep your own model. The one case that raises is `MELLONTOKEN_MODEL=auto` as well,
|
|
47
|
+
where there is no own model to keep.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
From the environment:
|
|
52
|
+
|
|
53
|
+
| variable | default |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `MELLONTOKEN_BASE_URL` | `http://localhost:8000/api/v1` |
|
|
56
|
+
| `MELLONTOKEN_API_KEY` | — required |
|
|
57
|
+
| `MELLONTOKEN_MODEL` | `gemini-3.7-flash` (or `auto`) |
|
|
58
|
+
| `MELLONTOKEN_TIER` | — (the effort classifier judges it) |
|
|
59
|
+
| `MELLONTOKEN_MAX_TOKENS` | `32000` |
|
|
60
|
+
| `MELLONTOKEN_TIMEOUT` | `600` |
|
|
61
|
+
|
|
62
|
+
or in code:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
mellontoken.configure(base_url="http://gateway:8000/api/v1", api_key=key, model="gpt-5.6-luna")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
From inside a container the host's gateway is `host.docker.internal`, not `localhost`.
|
|
69
|
+
|
|
70
|
+
## What `ask` does that a bare HTTP call does not
|
|
71
|
+
|
|
72
|
+
- **Sends the schema twice**, as `response_format` *and* as prompt text. The request
|
|
73
|
+
field is only honoured where the provider can enforce it; Gemini accepts a schema
|
|
74
|
+
it will then ignore, and the field names in the prompt are what stop it inventing
|
|
75
|
+
`col_idx` for `col`.
|
|
76
|
+
- **Reads JSON out of a fenced reply**, and wraps a bare list under the schema's
|
|
77
|
+
single array field when that is unambiguous.
|
|
78
|
+
- **Retries the shape, not the wire.** A 401/402/403/404 is final — a key, a budget,
|
|
79
|
+
a permission, a missing model — and raises immediately. A reply that did not parse
|
|
80
|
+
is retried three times.
|
|
81
|
+
- **Logs the cost of every call**, which is the reason to route through the gateway
|
|
82
|
+
at all: `mellon.cost_usd` is the only per-call price any provider path reports.
|
|
83
|
+
- **Resolves `model="auto"` before sending anything.** `auto` is a request for a
|
|
84
|
+
decision, not a catalogue slug, and the completion endpoint would 404 it. Resolved
|
|
85
|
+
once per `ask`, outside the retry loop: a reply that did not parse is a reason to
|
|
86
|
+
ask the same model again, not to pay three classifiers for the same decision again.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
Built from this directory, which lives in the gateway's own repo so the client and
|
|
91
|
+
the endpoint version together:
|
|
92
|
+
|
|
93
|
+
uv build # -> dist/mellontoken-0.1.0-py3-none-any.whl
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""The mellontoken client. One function: `ask(prompt)`.
|
|
2
|
+
|
|
3
|
+
import mellontoken
|
|
4
|
+
|
|
5
|
+
answer = await mellontoken.ask("Summarise this tender in one line.")
|
|
6
|
+
parsed = await mellontoken.ask(text, schema=DocExtraction)
|
|
7
|
+
routed = await mellontoken.ask(text, model="auto", tier="high") # it picks
|
|
8
|
+
cheap = await mellontoken.ask(text, model="auto", mode="cost") # …the cheapest
|
|
9
|
+
full = await mellontoken.ask_full(text, schema=DocExtraction) # + cost
|
|
10
|
+
|
|
11
|
+
Everything else — the endpoint, the key, the model, the retry, the JSON repair, the
|
|
12
|
+
cost line in the log — has a defensible default and lives here rather than at every
|
|
13
|
+
call site.
|
|
14
|
+
|
|
15
|
+
Configured from the environment (`MELLONTOKEN_BASE_URL`, `MELLONTOKEN_API_KEY`,
|
|
16
|
+
`MELLONTOKEN_MODEL`, `MELLONTOKEN_TIER`, `MELLONTOKEN_MODE`) or in code with
|
|
17
|
+
`configure()`. Nothing here knows about any
|
|
18
|
+
particular service: this package is what several of them share, and it is versioned
|
|
19
|
+
in the gateway's own repo so the client and the endpoint move together.
|
|
20
|
+
|
|
21
|
+
## Why a facade at all
|
|
22
|
+
|
|
23
|
+
The gateway is OpenAI-shaped, so `openai.AsyncOpenAI(base_url=...)` already works
|
|
24
|
+
and needs no wrapper. What it does not do is any of the four things every caller
|
|
25
|
+
ends up needing anyway: send the schema so the reply has the right field names, read
|
|
26
|
+
the JSON back out of a markdown fence, retry the shape rather than the network, and
|
|
27
|
+
record what the call cost. Four services doing that four times is where the bugs
|
|
28
|
+
were — and each of those four was a real bug found in one afternoon.
|
|
29
|
+
|
|
30
|
+
## What `ask` decides for you
|
|
31
|
+
|
|
32
|
+
**A schema is sent as both a request field and prompt text.** `response_format` is
|
|
33
|
+
the request field, and the gateway now forwards it — but only where the provider can
|
|
34
|
+
enforce it. Gemini's is an OpenAPI subset that silently accepts a schema it will not
|
|
35
|
+
honour, so the field names also go in the prompt. Belt and braces, cheaply: the
|
|
36
|
+
schema is a few hundred tokens and a failed extraction is a whole call.
|
|
37
|
+
|
|
38
|
+
**Retries are for the shape, not the wire.** httpx already retries nothing and the
|
|
39
|
+
gateway's own errors are final (a 402 is a budget, a 401 is a key — neither improves
|
|
40
|
+
on a second attempt). What is worth retrying is a reply that did not parse, which is
|
|
41
|
+
a different failure with a different fix.
|
|
42
|
+
|
|
43
|
+
**`model="auto"` is one command, not two.** The gateway resolves the model behind
|
|
44
|
+
`/api/auto/preview` and the completion goes to whatever it picked, in a single `ask`.
|
|
45
|
+
There is no public `route()`: `ask_full` already hands back the decision on
|
|
46
|
+
`Answer.route`, and a second entry point for the same question would be a second
|
|
47
|
+
place for `tier` handling to drift out of step. A caller that wants a decision
|
|
48
|
+
*without* a completion — building a workload file for `scripts/cost_summary`, say —
|
|
49
|
+
wants the HTTP endpoint, not a wrapper round it.
|
|
50
|
+
|
|
51
|
+
**`tier` and `mode` are two different questions.** `tier` is how much you will
|
|
52
|
+
spend; `mode` is what to optimise for once that is fixed. `mode="cost"` does not
|
|
53
|
+
make a call cheaper than its tier allows — it picks the cheapest model *that tier
|
|
54
|
+
admits*, so `tier="max", mode="cost"` is still a frontier model. Both are ignored
|
|
55
|
+
with a named `model=`, loudly, because a caller passing either alongside a slug has
|
|
56
|
+
misunderstood one of them.
|
|
57
|
+
|
|
58
|
+
**Cost is logged, never returned by `ask`.** `ask` gives you the answer because that
|
|
59
|
+
is what the name promises. `ask_full` gives you `Answer`, which carries the cost, the
|
|
60
|
+
model that actually served it and the token counts, for when you are the one paying
|
|
61
|
+
attention to that.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import dataclasses
|
|
67
|
+
import json
|
|
68
|
+
import logging
|
|
69
|
+
import os
|
|
70
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
71
|
+
|
|
72
|
+
import httpx
|
|
73
|
+
|
|
74
|
+
if TYPE_CHECKING:
|
|
75
|
+
from pydantic import BaseModel
|
|
76
|
+
|
|
77
|
+
log = logging.getLogger("mellontoken")
|
|
78
|
+
|
|
79
|
+
#: The version prefix is part of the URL. From inside a container the host's gateway
|
|
80
|
+
#: is `host.docker.internal`, not `localhost`.
|
|
81
|
+
BASE_URL = os.environ.get("MELLONTOKEN_BASE_URL", "http://localhost:8000/api/v1")
|
|
82
|
+
API_KEY = os.environ.get("MELLONTOKEN_API_KEY") or None
|
|
83
|
+
#: A catalogue slug, or `"auto"` to let the gateway's router pick one per prompt.
|
|
84
|
+
#: `auto` costs three extra classifier calls a call and adds their latency, so it is
|
|
85
|
+
#: opt-in rather than the default — see `route`.
|
|
86
|
+
MODEL = os.environ.get("MELLONTOKEN_MODEL", "gemini-3.7-flash")
|
|
87
|
+
#: The default cost band `auto` resolves within, when a call does not name one.
|
|
88
|
+
#: None lets the effort classifier judge it from the text; naming one here outranks
|
|
89
|
+
#: that, because a tier is a statement about what you will spend rather than a
|
|
90
|
+
#: property of the prompt.
|
|
91
|
+
TIER = os.environ.get("MELLONTOKEN_TIER") or None
|
|
92
|
+
#: What `auto` optimises for inside the tier it lands on: `performance` (the
|
|
93
|
+
#: default — the best model on the evidence), `cost` (the cheapest) or `latency`
|
|
94
|
+
#: (the fastest). It reorders *within* the band and never widens it, so `cost` is
|
|
95
|
+
#: the cheapest model that tier admits rather than the cheapest model there is —
|
|
96
|
+
#: a `max` prompt asked for the cheapest still comes back with a frontier model.
|
|
97
|
+
MODE = os.environ.get("MELLONTOKEN_MODE") or "performance"
|
|
98
|
+
#: Caps reasoning *and* output together, and reasoning is spent first — a small
|
|
99
|
+
#: budget on a thinking model returns `finish_reason=length` and an empty answer.
|
|
100
|
+
MAX_TOKENS = int(os.environ.get("MELLONTOKEN_MAX_TOKENS", "32000"))
|
|
101
|
+
#: Wall-clock cap per attempt.
|
|
102
|
+
TIMEOUT = float(os.environ.get("MELLONTOKEN_TIMEOUT", "600"))
|
|
103
|
+
|
|
104
|
+
_ATTEMPTS = 3
|
|
105
|
+
_HTTP_OK = 200
|
|
106
|
+
#: Ask the gateway to pick the model. Not a catalogue slug — `ask` resolves it to
|
|
107
|
+
#: one before sending anything, so the completion request never carries this.
|
|
108
|
+
AUTO = "auto"
|
|
109
|
+
#: A key, a budget or a permission. None of them improves on a retry.
|
|
110
|
+
_FINAL = frozenset({401, 402, 403, 404})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def configure(
|
|
114
|
+
*,
|
|
115
|
+
base_url: str | None = None,
|
|
116
|
+
api_key: str | None = None,
|
|
117
|
+
model: str | None = None,
|
|
118
|
+
tier: str | None = None,
|
|
119
|
+
mode: str | None = None,
|
|
120
|
+
max_tokens: int | None = None,
|
|
121
|
+
timeout: float | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Set any of the defaults in code, for a service that does not use env vars.
|
|
124
|
+
|
|
125
|
+
Module-level rather than a client object because there is one gateway per
|
|
126
|
+
deployment and passing the same four values through every call site is the
|
|
127
|
+
ceremony this package exists to remove. Pass `model=` to a single `ask` when one
|
|
128
|
+
call wants something different.
|
|
129
|
+
"""
|
|
130
|
+
global BASE_URL, API_KEY, MODEL, TIER, MODE, MAX_TOKENS, TIMEOUT # noqa: PLW0603
|
|
131
|
+
if base_url is not None:
|
|
132
|
+
BASE_URL = base_url
|
|
133
|
+
if api_key is not None:
|
|
134
|
+
API_KEY = api_key
|
|
135
|
+
if model is not None:
|
|
136
|
+
MODEL = model
|
|
137
|
+
if tier is not None:
|
|
138
|
+
TIER = tier
|
|
139
|
+
if mode is not None:
|
|
140
|
+
MODE = mode
|
|
141
|
+
if max_tokens is not None:
|
|
142
|
+
MAX_TOKENS = max_tokens
|
|
143
|
+
if timeout is not None:
|
|
144
|
+
TIMEOUT = timeout
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class MellontokenError(RuntimeError):
|
|
148
|
+
"""The gateway refused, or three replies in a row did not parse."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
152
|
+
class Route:
|
|
153
|
+
"""What the router decided, and enough of the reasoning to argue with it.
|
|
154
|
+
|
|
155
|
+
`model_slug` is None when nothing survived — no grid cell to read, or the
|
|
156
|
+
capability gate rejected every candidate in it. That is an answer rather than a
|
|
157
|
+
failure, and `explanation` says which; `ask` falls back to the configured model.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
#: None when nothing survived the gate. `explanation` says why.
|
|
161
|
+
model_slug: str | None
|
|
162
|
+
#: The grid row it read. `other` when no tag fit — see `task_from_fallback`.
|
|
163
|
+
task: str | None
|
|
164
|
+
#: True when `task` is the `other` fallback row rather than a reading of the
|
|
165
|
+
#: text. `other` chosen and `other` fallen back to are different claims.
|
|
166
|
+
task_from_fallback: bool
|
|
167
|
+
#: The cost band, from `tier=` if you gave one and the classifier otherwise.
|
|
168
|
+
tier: str | None
|
|
169
|
+
#: True when the tier came from you, so the effort classifier was advisory.
|
|
170
|
+
tier_from_caller: bool
|
|
171
|
+
#: Capabilities the prompt needs, e.g. `["tools", "json_mode"]`.
|
|
172
|
+
required: tuple[str, ...]
|
|
173
|
+
#: `assigned` (an operator filled that cell) | `derived` (ranked from
|
|
174
|
+
#: evidence) | `none`.
|
|
175
|
+
source: str
|
|
176
|
+
#: What the cell offered in order, before the gate.
|
|
177
|
+
shortlist: tuple[str, ...]
|
|
178
|
+
#: One paragraph you can check the decision against. Worth logging on a
|
|
179
|
+
#: surprising pick.
|
|
180
|
+
explanation: str
|
|
181
|
+
#: The classifier calls, which are billed separately from the completion.
|
|
182
|
+
input_tokens: int
|
|
183
|
+
output_tokens: int
|
|
184
|
+
latency_ms: int
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
188
|
+
class Answer:
|
|
189
|
+
"""What came back, and what it cost."""
|
|
190
|
+
|
|
191
|
+
text: str
|
|
192
|
+
model: str
|
|
193
|
+
cost_usd: str
|
|
194
|
+
input_tokens: int
|
|
195
|
+
output_tokens: int
|
|
196
|
+
#: Part of `output_tokens`, billed at the output rate. Often most of it.
|
|
197
|
+
reasoning_tokens: int | None
|
|
198
|
+
finish_reason: str
|
|
199
|
+
#: Set when a `schema=` was given: the validated object.
|
|
200
|
+
parsed: Any = None
|
|
201
|
+
#: Set when the model was resolved rather than named — `model="auto"`. Carries
|
|
202
|
+
#: the classifier spend, which `cost_usd` does not: that is the completion's
|
|
203
|
+
#: price and the routing was billed separately.
|
|
204
|
+
route: Route | None = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _client(base_url: str | None = None) -> httpx.AsyncClient:
|
|
208
|
+
if not API_KEY:
|
|
209
|
+
raise MellontokenError("MELLONTOKEN_API_KEY is not set")
|
|
210
|
+
return httpx.AsyncClient(
|
|
211
|
+
base_url=base_url or BASE_URL,
|
|
212
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
213
|
+
timeout=TIMEOUT,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _api_root() -> str:
|
|
218
|
+
"""`.../api/v1` -> `.../api`, for the control-plane endpoints.
|
|
219
|
+
|
|
220
|
+
The router is not part of the OpenAI-shaped surface and is deliberately not
|
|
221
|
+
versioned with it: `/v1` is a promise about a wire format somebody else defined,
|
|
222
|
+
and `/api/auto/preview` is ours. Derived rather than configured, so one env var
|
|
223
|
+
still points the client at one deployment.
|
|
224
|
+
"""
|
|
225
|
+
root = BASE_URL.rstrip("/")
|
|
226
|
+
return root.removesuffix("/v1")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _schema_of(schema: type[BaseModel]) -> dict[str, Any]:
|
|
230
|
+
"""Return a JSON schema with the two keys strict mode needs; Pydantic omits both."""
|
|
231
|
+
body = schema.model_json_schema()
|
|
232
|
+
_harden(body)
|
|
233
|
+
return {"type": "json_schema", "json_schema": {"name": schema.__name__, "schema": body, "strict": True}}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _harden(node: object) -> None:
|
|
237
|
+
"""Close every object and require every property, in place."""
|
|
238
|
+
if isinstance(node, dict):
|
|
239
|
+
if node.get("type") == "object" and isinstance(node.get("properties"), dict):
|
|
240
|
+
node["additionalProperties"] = False
|
|
241
|
+
node["required"] = list(node["properties"])
|
|
242
|
+
for value in node.values():
|
|
243
|
+
_harden(value)
|
|
244
|
+
elif isinstance(node, list):
|
|
245
|
+
for item in node:
|
|
246
|
+
_harden(item)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _schema_prompt(schema: type[BaseModel]) -> str:
|
|
250
|
+
"""Render the field names as prompt text, for providers that cannot enforce a schema."""
|
|
251
|
+
body = schema.model_json_schema()
|
|
252
|
+
_strip_prose(body)
|
|
253
|
+
return (
|
|
254
|
+
"\n\nReturn ONLY a JSON object matching this schema exactly — every field "
|
|
255
|
+
"name spelled as written here, no extra fields, no markdown fence:\n"
|
|
256
|
+
+ json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _strip_prose(node: object) -> None:
|
|
261
|
+
"""Drop `title`/`description`, which are most of a Pydantic schema's tokens."""
|
|
262
|
+
if isinstance(node, dict):
|
|
263
|
+
node.pop("title", None)
|
|
264
|
+
node.pop("description", None)
|
|
265
|
+
for value in node.values():
|
|
266
|
+
_strip_prose(value)
|
|
267
|
+
elif isinstance(node, list):
|
|
268
|
+
for item in node:
|
|
269
|
+
_strip_prose(item)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _slug(picked: Route | None, model: str | None) -> str:
|
|
273
|
+
"""Return the catalogue slug to send. Never the literal `"auto"`.
|
|
274
|
+
|
|
275
|
+
Order: what the router resolved, then the call's own `model=`, then the module
|
|
276
|
+
default — skipping `"auto"` wherever it appears, since it is a request for a
|
|
277
|
+
decision rather than a model and the gateway would 404 it as an unknown slug.
|
|
278
|
+
"""
|
|
279
|
+
if picked is not None and picked.model_slug:
|
|
280
|
+
return picked.model_slug
|
|
281
|
+
for candidate in (model, MODEL):
|
|
282
|
+
if candidate and candidate != AUTO:
|
|
283
|
+
return candidate
|
|
284
|
+
raise MellontokenError("no model to send: both `model=` and MELLONTOKEN_MODEL are 'auto'")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _loads(content: str, schema: type[BaseModel]) -> object:
|
|
288
|
+
"""Read JSON out of a reply, repairing the two shapes models actually return.
|
|
289
|
+
|
|
290
|
+
A ```json fence, and a bare list where the schema wants an object holding one
|
|
291
|
+
list — wrapped under that field's name, but only when the schema has exactly one
|
|
292
|
+
array field, so the guess cannot be ambiguous.
|
|
293
|
+
"""
|
|
294
|
+
text = content.strip()
|
|
295
|
+
if text.startswith("```"):
|
|
296
|
+
text = text.split("\n", 1)[-1] if "\n" in text else text
|
|
297
|
+
text = text.rsplit("```", 1)[0].strip()
|
|
298
|
+
|
|
299
|
+
data = json.loads(text)
|
|
300
|
+
if isinstance(data, list):
|
|
301
|
+
props = schema.model_json_schema().get("properties", {})
|
|
302
|
+
arrays = [name for name, spec in props.items() if spec.get("type") == "array"]
|
|
303
|
+
if len(arrays) == 1:
|
|
304
|
+
return {arrays[0]: data}
|
|
305
|
+
return data
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _preview(
|
|
309
|
+
prompt: str,
|
|
310
|
+
*,
|
|
311
|
+
tier: str | None = None,
|
|
312
|
+
mode: str | None = None,
|
|
313
|
+
label: str = "route",
|
|
314
|
+
) -> Route:
|
|
315
|
+
"""Ask the gateway which model this prompt should go to. `POST /api/auto/preview`.
|
|
316
|
+
|
|
317
|
+
Internal: `ask(model="auto")` is the way to reach this, and `Answer.route` is
|
|
318
|
+
where the decision comes back. There is no public `route()` — it would be a
|
|
319
|
+
second entry point for something `ask` already returns, and a caller who wants
|
|
320
|
+
the decision without a completion wants the HTTP endpoint rather than a wrapper
|
|
321
|
+
round it.
|
|
322
|
+
|
|
323
|
+
Three classifier calls — task, effort, capability — run concurrently on the
|
|
324
|
+
gateway and reduce to one grid cell. They are billed and appear on the ledger as
|
|
325
|
+
`kind=classifier` rows, so this is not free advice: roughly $0.005 a resolution
|
|
326
|
+
against a completion's dollars.
|
|
327
|
+
|
|
328
|
+
`tier` outranks the effort classifier — it is a statement about what you will
|
|
329
|
+
spend rather than a property of the text. `mode` then decides what to optimise
|
|
330
|
+
for inside whatever tier that lands on; it is omitted from the payload at its
|
|
331
|
+
default, so an older gateway that has never heard of it still answers.
|
|
332
|
+
"""
|
|
333
|
+
payload: dict[str, Any] = {"prompt": prompt}
|
|
334
|
+
if tier or TIER:
|
|
335
|
+
payload["tier"] = tier or TIER
|
|
336
|
+
chosen = mode or MODE
|
|
337
|
+
if chosen and chosen != "performance":
|
|
338
|
+
payload["mode"] = chosen
|
|
339
|
+
|
|
340
|
+
async with _client(_api_root()) as http:
|
|
341
|
+
response = await http.post("/auto/preview", json=payload)
|
|
342
|
+
if response.status_code != _HTTP_OK:
|
|
343
|
+
raise MellontokenError(
|
|
344
|
+
f"{label}: gateway refused ({response.status_code}): {response.text[:300]}",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
body = response.json()
|
|
348
|
+
picked = Route(
|
|
349
|
+
model_slug=body.get("model_slug"),
|
|
350
|
+
task=body.get("task"),
|
|
351
|
+
task_from_fallback=bool(body.get("task_from_fallback")),
|
|
352
|
+
tier=body.get("tier"),
|
|
353
|
+
tier_from_caller=bool(body.get("tier_from_caller")),
|
|
354
|
+
required=tuple(body.get("required") or ()),
|
|
355
|
+
source=body.get("source", "none"),
|
|
356
|
+
shortlist=tuple(body.get("shortlist") or ()),
|
|
357
|
+
explanation=body.get("explanation", ""),
|
|
358
|
+
input_tokens=int(body.get("input_tokens", 0)),
|
|
359
|
+
output_tokens=int(body.get("output_tokens", 0)),
|
|
360
|
+
latency_ms=int(body.get("latency_ms", 0)),
|
|
361
|
+
)
|
|
362
|
+
log.info(
|
|
363
|
+
"%s model=%s task=%s%s tier=%s%s source=%s in=%d out=%d %dms",
|
|
364
|
+
label, picked.model_slug, picked.task,
|
|
365
|
+
" (no tag fit)" if picked.task_from_fallback else "",
|
|
366
|
+
picked.tier, " (yours)" if picked.tier_from_caller else "",
|
|
367
|
+
picked.source, picked.input_tokens, picked.output_tokens, picked.latency_ms,
|
|
368
|
+
)
|
|
369
|
+
return picked
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def _resolve(
|
|
373
|
+
prompt: str,
|
|
374
|
+
*,
|
|
375
|
+
model: str | None,
|
|
376
|
+
tier: str | None,
|
|
377
|
+
mode: str | None,
|
|
378
|
+
label: str,
|
|
379
|
+
) -> Route | None:
|
|
380
|
+
"""Route the prompt when `model` is `auto`, else None. Raises only when stuck.
|
|
381
|
+
|
|
382
|
+
Called once per `ask`, outside its retry loop: a reply that did not parse is a
|
|
383
|
+
reason to ask the same model again, not to re-run three classifiers on an
|
|
384
|
+
unchanged prompt and be billed for the same decision three times.
|
|
385
|
+
|
|
386
|
+
A router that resolves nothing is a real answer, not a failure, and the fallback
|
|
387
|
+
is what the gateway's own preview tells a caller to do — keep your own model.
|
|
388
|
+
Raising instead would make `auto` less reliable than naming a slug, which is the
|
|
389
|
+
opposite of the point. The one case with no way forward is `auto` as the module
|
|
390
|
+
default too, where there is no own model to keep.
|
|
391
|
+
"""
|
|
392
|
+
if (model or MODEL) != AUTO:
|
|
393
|
+
# `tier` only means something to the router. Named model + tier is a caller
|
|
394
|
+
# who thinks they are bounding a cost and is not, so it is said out loud
|
|
395
|
+
# rather than dropped — the two arguments arriving together is the whole
|
|
396
|
+
# signal that one of them was misunderstood.
|
|
397
|
+
for name, value in (("tier", tier), ("mode", mode)):
|
|
398
|
+
if value:
|
|
399
|
+
log.warning(
|
|
400
|
+
"%s: %s=%r ignored — it steers the router, and model=%r names a "
|
|
401
|
+
"model outright. Pass model='auto' to use it.",
|
|
402
|
+
label, name, value, model or MODEL,
|
|
403
|
+
)
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
picked = await _preview(prompt, tier=tier, mode=mode, label=f"{label}:route")
|
|
407
|
+
if picked.model_slug is not None:
|
|
408
|
+
return picked
|
|
409
|
+
|
|
410
|
+
if MODEL == AUTO and (model is None or model == AUTO):
|
|
411
|
+
raise MellontokenError(
|
|
412
|
+
f"{label}: the router resolved no model, and there is no non-auto default "
|
|
413
|
+
f"to fall back to. {picked.explanation}",
|
|
414
|
+
)
|
|
415
|
+
log.warning("%s: router resolved nothing, falling back — %s", label, picked.explanation)
|
|
416
|
+
return picked
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def ask_full(
|
|
420
|
+
prompt: str,
|
|
421
|
+
*,
|
|
422
|
+
system: str | None = None,
|
|
423
|
+
schema: type[BaseModel] | None = None,
|
|
424
|
+
model: str | None = None,
|
|
425
|
+
tier: str | None = None,
|
|
426
|
+
mode: str | None = None,
|
|
427
|
+
max_tokens: int | None = None,
|
|
428
|
+
label: str = "ask",
|
|
429
|
+
) -> Answer:
|
|
430
|
+
"""Ask, and get the answer with its cost and token counts.
|
|
431
|
+
|
|
432
|
+
`label` names the call in the log line — worth setting when a service makes
|
|
433
|
+
several kinds of call, because the log is the only per-call cost record there is.
|
|
434
|
+
|
|
435
|
+
`model="auto"` resolves the model through `route` first and reports the decision
|
|
436
|
+
on `Answer.route`. `tier` and `mode` are only read on that path: `tier` bounds
|
|
437
|
+
what the router may spend, `mode` says what to optimise for inside that bound —
|
|
438
|
+
`performance` (default), `cost` or `latency`.
|
|
439
|
+
"""
|
|
440
|
+
picked = await _resolve(prompt, model=model, tier=tier, mode=mode, label=label)
|
|
441
|
+
|
|
442
|
+
messages = []
|
|
443
|
+
if system:
|
|
444
|
+
messages.append({"role": "system", "content": system})
|
|
445
|
+
messages.append({"role": "user", "content": prompt + (_schema_prompt(schema) if schema else "")})
|
|
446
|
+
|
|
447
|
+
payload: dict[str, Any] = {
|
|
448
|
+
# `picked.model_slug` when the router resolved one; never the literal
|
|
449
|
+
# `"auto"`, which the gateway would 404 as an unknown slug.
|
|
450
|
+
"model": _slug(picked, model),
|
|
451
|
+
"messages": messages,
|
|
452
|
+
"max_tokens": max_tokens or MAX_TOKENS,
|
|
453
|
+
}
|
|
454
|
+
if schema is not None:
|
|
455
|
+
payload["response_format"] = _schema_of(schema)
|
|
456
|
+
|
|
457
|
+
last: str | None = None
|
|
458
|
+
async with _client() as http:
|
|
459
|
+
for attempt in range(_ATTEMPTS):
|
|
460
|
+
response = await http.post("/chat/completions", json=payload)
|
|
461
|
+
if response.status_code in _FINAL:
|
|
462
|
+
raise MellontokenError(f"gateway refused ({response.status_code}): {response.text[:300]}")
|
|
463
|
+
if response.status_code != _HTTP_OK:
|
|
464
|
+
last = f"HTTP {response.status_code}: {response.text[:200]}"
|
|
465
|
+
log.warning("%s attempt %d %s", label, attempt + 1, last)
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
body = response.json()
|
|
469
|
+
choice = body["choices"][0]
|
|
470
|
+
mellon = body.get("mellon") or {}
|
|
471
|
+
usage = body.get("usage") or {}
|
|
472
|
+
answer = Answer(
|
|
473
|
+
text=choice["message"].get("content") or "",
|
|
474
|
+
model=mellon.get("served_model", payload["model"]),
|
|
475
|
+
cost_usd=str(mellon.get("cost_usd", "?")),
|
|
476
|
+
input_tokens=int(usage.get("prompt_tokens", 0)),
|
|
477
|
+
output_tokens=int(usage.get("completion_tokens", 0)),
|
|
478
|
+
reasoning_tokens=mellon.get("reasoning_tokens"),
|
|
479
|
+
finish_reason=choice.get("finish_reason", ""),
|
|
480
|
+
route=picked,
|
|
481
|
+
)
|
|
482
|
+
log.info(
|
|
483
|
+
"%s model=%s in=%d out=%d reasoning=%s cost=$%s finish=%s",
|
|
484
|
+
label, answer.model, answer.input_tokens, answer.output_tokens,
|
|
485
|
+
answer.reasoning_tokens, answer.cost_usd, answer.finish_reason,
|
|
486
|
+
)
|
|
487
|
+
if mellon.get("refused"):
|
|
488
|
+
raise MellontokenError(f"{label}: the model refused")
|
|
489
|
+
if schema is None:
|
|
490
|
+
return answer
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
return dataclasses.replace(
|
|
494
|
+
answer, parsed=schema.model_validate(_loads(answer.text, schema))
|
|
495
|
+
)
|
|
496
|
+
except Exception as exc: # noqa: BLE001 - json or validation, both retryable
|
|
497
|
+
# The message, not just the class: "ValidationError" three times over
|
|
498
|
+
# says nothing about which field was wrong.
|
|
499
|
+
last = f"{type(exc).__name__} (finish={answer.finish_reason}): {str(exc)[:200]}"
|
|
500
|
+
log.warning("%s attempt %d %s", label, attempt + 1, last)
|
|
501
|
+
|
|
502
|
+
raise MellontokenError(f"{label}: failed after {_ATTEMPTS} attempts: {last}")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@overload
|
|
506
|
+
async def ask(prompt: str, *, schema: type[BaseModel], **kw: Any) -> BaseModel: ...
|
|
507
|
+
@overload
|
|
508
|
+
async def ask(prompt: str, **kw: Any) -> str: ...
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def ask(prompt: str, **kw: Any) -> Any:
|
|
512
|
+
"""Ask, and get the answer. The whole point of this module.
|
|
513
|
+
|
|
514
|
+
Returns the reply text, or — with `schema=` — a validated instance of it. Pass
|
|
515
|
+
`model="auto"` to have the gateway pick the model; `ask_full` is the way to see
|
|
516
|
+
which one it picked.
|
|
517
|
+
"""
|
|
518
|
+
answer = await ask_full(prompt, **kw)
|
|
519
|
+
return answer.parsed if kw.get("schema") is not None else answer.text
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
__all__ = [
|
|
523
|
+
"AUTO",
|
|
524
|
+
"Answer",
|
|
525
|
+
"MellontokenError",
|
|
526
|
+
#: Exported as a type, not an entry point: it is what `Answer.route` holds.
|
|
527
|
+
"Route",
|
|
528
|
+
"ask",
|
|
529
|
+
"ask_full",
|
|
530
|
+
"configure",
|
|
531
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mellontoken"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Client for the mellontoken gateway: ask(prompt), and it tells you what it cost."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["httpx>=0.27"]
|
|
8
|
+
|
|
9
|
+
[project.optional-dependencies]
|
|
10
|
+
# Only needed for `schema=`; a caller asking for plain text does not need pydantic.
|
|
11
|
+
schema = ["pydantic>=2.0"]
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["mellontoken"]
|