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.
@@ -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"]