offpeak 0.1.2__tar.gz → 0.2.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.
- {offpeak-0.1.2 → offpeak-0.2.0}/.github/workflows/ci.yml +1 -1
- offpeak-0.2.0/.github/workflows/nightly.yml +74 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/PKG-INFO +30 -1
- {offpeak-0.1.2 → offpeak-0.2.0}/README.md +29 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/pyproject.toml +1 -1
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/__init__.py +7 -1
- offpeak-0.2.0/src/offpeak/__main__.py +62 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/client.py +11 -10
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/deadline.py +8 -2
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/job.py +16 -1
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/prices.py +19 -0
- offpeak-0.2.0/src/offpeak/quote.py +248 -0
- offpeak-0.2.0/tests/conftest.py +19 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/tests/test_deadline.py +17 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/tests/test_job_receipt.py +7 -0
- offpeak-0.2.0/tests/test_night_report.py +178 -0
- offpeak-0.2.0/tests/test_quote.py +204 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/tests/test_run.py +45 -0
- offpeak-0.2.0/tools/night_report.py +283 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/.github/workflows/publish.yml +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/.gitignore +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/CONTRIBUTING.md +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/LICENSE +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/SPEC.md +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/venues/__init__.py +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/venues/anthropic_batch.py +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/venues/base.py +0 -0
- {offpeak-0.1.2 → offpeak-0.2.0}/src/offpeak/venues/openai_batch.py +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: Night board
|
|
2
|
+
|
|
3
|
+
# Quotes at dusk, marks at dawn. Output is committed to the `board-data`
|
|
4
|
+
# branch, not to main: main is protected (PR + four required checks), so a
|
|
5
|
+
# nightly bot push there would be rejected every night.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: "0 19 * * *" # quote — the night ahead, carbon forecast
|
|
10
|
+
- cron: "30 6 * * *" # mark — the night just finished, carbon actuals
|
|
11
|
+
workflow_dispatch:
|
|
12
|
+
inputs:
|
|
13
|
+
mode:
|
|
14
|
+
description: "Which pass to run"
|
|
15
|
+
required: true
|
|
16
|
+
default: quote
|
|
17
|
+
type: choice
|
|
18
|
+
options: [quote, mark]
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: write
|
|
22
|
+
|
|
23
|
+
concurrency:
|
|
24
|
+
group: night-board
|
|
25
|
+
cancel-in-progress: false
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
board:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- name: Check out the generator (main)
|
|
32
|
+
uses: actions/checkout@v7
|
|
33
|
+
|
|
34
|
+
- uses: actions/setup-python@v7
|
|
35
|
+
with:
|
|
36
|
+
python-version: "3.12"
|
|
37
|
+
|
|
38
|
+
- name: Check out the ledger (board-data)
|
|
39
|
+
uses: actions/checkout@v7
|
|
40
|
+
with:
|
|
41
|
+
ref: board-data
|
|
42
|
+
path: board
|
|
43
|
+
|
|
44
|
+
- name: Pick the pass
|
|
45
|
+
id: pick
|
|
46
|
+
run: |
|
|
47
|
+
if [ -n "${{ inputs.mode }}" ]; then
|
|
48
|
+
mode="${{ inputs.mode }}"
|
|
49
|
+
elif [ "${{ github.event.schedule }}" = "0 19 * * *" ]; then
|
|
50
|
+
mode=quote
|
|
51
|
+
else
|
|
52
|
+
mode=mark
|
|
53
|
+
fi
|
|
54
|
+
echo "mode=$mode" >> "$GITHUB_OUTPUT"
|
|
55
|
+
echo "running the $mode pass"
|
|
56
|
+
|
|
57
|
+
- name: Generate
|
|
58
|
+
run: |
|
|
59
|
+
python tools/night_report.py \
|
|
60
|
+
--mode "${{ steps.pick.outputs.mode }}" \
|
|
61
|
+
--outdir board/nightly
|
|
62
|
+
|
|
63
|
+
- name: Commit to board-data
|
|
64
|
+
working-directory: board
|
|
65
|
+
run: |
|
|
66
|
+
git config user.name "github-actions[bot]"
|
|
67
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
68
|
+
git add nightly
|
|
69
|
+
if git diff --staged --quiet; then
|
|
70
|
+
echo "nothing changed — not committing an empty night"
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
git commit -m "board: ${{ steps.pick.outputs.mode }} $(date -u +%Y-%m-%d)"
|
|
74
|
+
git push
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: offpeak
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Deadline-priced inference: give AI jobs a deadline and run them on the cheapest venue — provider batch tiers (−50%) today. Same model, same tokens, a different hour.
|
|
5
5
|
Project-URL: Homepage, https://github.com/offpeak-ai/offpeak
|
|
6
6
|
Project-URL: Repository, https://github.com/offpeak-ai/offpeak
|
|
@@ -72,6 +72,7 @@ prices snapshot 2026-08 — override via offpeak.prices
|
|
|
72
72
|
|
|
73
73
|
## What it does
|
|
74
74
|
|
|
75
|
+
- **Know the price before you spend it.** `quote(jobs, deadline=...)` prices a run against the published sheets with no API calls and no key — list versus batch, per venue, plus what the wait is worth.
|
|
75
76
|
- **One argument, not a workflow.** `run(jobs, deadline=...)` handles batching, submission, polling, collection, and result matching across providers.
|
|
76
77
|
- **Deadlines are guarded, not hoped for.** If a batch hasn't landed by the time the remaining window shrinks to a risk buffer, `offpeak` cancels and re-runs the stragglers synchronously at list price. You state the deadline; it gets met.
|
|
77
78
|
- **Every run settles a receipt.** List cost, paid cost, captured spread — arithmetic against public price sheets, not estimates.
|
|
@@ -88,6 +89,32 @@ pip install "offpeak[openai]"
|
|
|
88
89
|
|
|
89
90
|
Venues use the standard environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`), or pass a configured client: `OpenAIBatch(client=my_client)`.
|
|
90
91
|
|
|
92
|
+
## The free quote
|
|
93
|
+
|
|
94
|
+
What is the wait worth? Ask before you spend anything — `quote()` makes no API calls and needs no key.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python -m offpeak quote --model gpt-5.6-luna --input-tokens 800 --output-tokens 200 --jobs 5000
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
OFFPEAK QUOTE ─────────────────────────────────
|
|
102
|
+
jobs 5000 across 1 venue(s)
|
|
103
|
+
deadline 2026-08-21 21:11 PDT (24.0h out)
|
|
104
|
+
tokens 4,000,000 in · 1,000,000 out
|
|
105
|
+
|
|
106
|
+
openai:batch 5000 job(s) list $1.00 batch $0.50 save $0.50 (50.0%)
|
|
107
|
+
|
|
108
|
+
list $1.00 (run now, synchronously)
|
|
109
|
+
batch $0.50 (run by the deadline)
|
|
110
|
+
save $0.50 (50.0%)
|
|
111
|
+
basis input explicit; output explicit
|
|
112
|
+
prices snapshot 2026-08 — estimate only, not a bill
|
|
113
|
+
───────────────────────────────────────────────
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
From Python, `offpeak.quote(jobs, deadline="06:00")` takes the same jobs you would pass to `run()`. Token counts come from the job where it knows them (`metadata={"input_tokens": ..., "output_tokens": ...}`, or `max_tokens` as an output ceiling) and are a labeled chars/4 estimate where it does not — every figure reports its provenance in `basis`, and a quote with no output signal is marked a **floor**, not an estimate.
|
|
117
|
+
|
|
91
118
|
## Deadlines
|
|
92
119
|
|
|
93
120
|
Deadlines are how software says "this can wait" — the full semantics live in [SPEC.md](SPEC.md).
|
|
@@ -131,6 +158,8 @@ Unknown models settle with `cost = None` rather than a guess.
|
|
|
131
158
|
|
|
132
159
|
`offpeak` is the open client and spec for a simple claim: **intelligence has a time value**. A large share of AI work — embeddings, evals, backfills, report generation, overnight agents — has no human waiting on it, and the venues already price that patience at −50%. This library is the missing workflow.
|
|
133
160
|
|
|
161
|
+
The **[night board](https://github.com/offpeak-ai/offpeak/blob/board-data/nightly/BOARD.md)** marks the same claim against open grid data every night — power and carbon peak/off-peak spreads, alongside the 2.0x token spread the batch tiers already publish.
|
|
162
|
+
|
|
134
163
|
The roadmap follows the same interface upward: more venues (Google batch, spot capacity, off-peak windows on your own GPUs), queue-latency forecasting instead of a fixed risk buffer, portfolio placement across venues, energy- and carbon-aware scheduling with per-job receipts. The venue interface (`offpeak.Venue`) is deliberately the extension point — a venue is anywhere deferred work can run.
|
|
135
164
|
|
|
136
165
|
A hosted desk that does the forecasting, cross-venue portfolio scheduling, and SLA insurance at fleet scale — payloads never leaving your perimeter — is being built by the same team. The SDK and the deadline spec stay open, Apache-2.0.
|
|
@@ -35,6 +35,7 @@ prices snapshot 2026-08 — override via offpeak.prices
|
|
|
35
35
|
|
|
36
36
|
## What it does
|
|
37
37
|
|
|
38
|
+
- **Know the price before you spend it.** `quote(jobs, deadline=...)` prices a run against the published sheets with no API calls and no key — list versus batch, per venue, plus what the wait is worth.
|
|
38
39
|
- **One argument, not a workflow.** `run(jobs, deadline=...)` handles batching, submission, polling, collection, and result matching across providers.
|
|
39
40
|
- **Deadlines are guarded, not hoped for.** If a batch hasn't landed by the time the remaining window shrinks to a risk buffer, `offpeak` cancels and re-runs the stragglers synchronously at list price. You state the deadline; it gets met.
|
|
40
41
|
- **Every run settles a receipt.** List cost, paid cost, captured spread — arithmetic against public price sheets, not estimates.
|
|
@@ -51,6 +52,32 @@ pip install "offpeak[openai]"
|
|
|
51
52
|
|
|
52
53
|
Venues use the standard environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`), or pass a configured client: `OpenAIBatch(client=my_client)`.
|
|
53
54
|
|
|
55
|
+
## The free quote
|
|
56
|
+
|
|
57
|
+
What is the wait worth? Ask before you spend anything — `quote()` makes no API calls and needs no key.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m offpeak quote --model gpt-5.6-luna --input-tokens 800 --output-tokens 200 --jobs 5000
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
OFFPEAK QUOTE ─────────────────────────────────
|
|
65
|
+
jobs 5000 across 1 venue(s)
|
|
66
|
+
deadline 2026-08-21 21:11 PDT (24.0h out)
|
|
67
|
+
tokens 4,000,000 in · 1,000,000 out
|
|
68
|
+
|
|
69
|
+
openai:batch 5000 job(s) list $1.00 batch $0.50 save $0.50 (50.0%)
|
|
70
|
+
|
|
71
|
+
list $1.00 (run now, synchronously)
|
|
72
|
+
batch $0.50 (run by the deadline)
|
|
73
|
+
save $0.50 (50.0%)
|
|
74
|
+
basis input explicit; output explicit
|
|
75
|
+
prices snapshot 2026-08 — estimate only, not a bill
|
|
76
|
+
───────────────────────────────────────────────
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
From Python, `offpeak.quote(jobs, deadline="06:00")` takes the same jobs you would pass to `run()`. Token counts come from the job where it knows them (`metadata={"input_tokens": ..., "output_tokens": ...}`, or `max_tokens` as an output ceiling) and are a labeled chars/4 estimate where it does not — every figure reports its provenance in `basis`, and a quote with no output signal is marked a **floor**, not an estimate.
|
|
80
|
+
|
|
54
81
|
## Deadlines
|
|
55
82
|
|
|
56
83
|
Deadlines are how software says "this can wait" — the full semantics live in [SPEC.md](SPEC.md).
|
|
@@ -94,6 +121,8 @@ Unknown models settle with `cost = None` rather than a guess.
|
|
|
94
121
|
|
|
95
122
|
`offpeak` is the open client and spec for a simple claim: **intelligence has a time value**. A large share of AI work — embeddings, evals, backfills, report generation, overnight agents — has no human waiting on it, and the venues already price that patience at −50%. This library is the missing workflow.
|
|
96
123
|
|
|
124
|
+
The **[night board](https://github.com/offpeak-ai/offpeak/blob/board-data/nightly/BOARD.md)** marks the same claim against open grid data every night — power and carbon peak/off-peak spreads, alongside the 2.0x token spread the batch tiers already publish.
|
|
125
|
+
|
|
97
126
|
The roadmap follows the same interface upward: more venues (Google batch, spot capacity, off-peak windows on your own GPUs), queue-latency forecasting instead of a fixed risk buffer, portfolio placement across venues, energy- and carbon-aware scheduling with per-job receipts. The venue interface (`offpeak.Venue`) is deliberately the extension point — a venue is anywhere deferred work can run.
|
|
98
127
|
|
|
99
128
|
A hosted desk that does the forecasting, cross-venue portfolio scheduling, and SLA insurance at fleet scale — payloads never leaving your perimeter — is being built by the same team. The SDK and the deadline spec stay open, Apache-2.0.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "offpeak"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Deadline-priced inference: give AI jobs a deadline and run them on the cheapest venue — provider batch tiers (−50%) today. Same model, same tokens, a different hour."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -14,24 +14,30 @@ from . import prices
|
|
|
14
14
|
from .client import Settlement, default_venues, receipt, run
|
|
15
15
|
from .deadline import parse_deadline, seconds_until
|
|
16
16
|
from .job import Job, Receipt, Result, Status, job
|
|
17
|
+
from .prices import format_usd
|
|
18
|
+
from .quote import Quote, VenueQuote, quote
|
|
17
19
|
from .venues.base import BatchState, Venue
|
|
18
20
|
|
|
19
|
-
__version__ = "0.
|
|
21
|
+
__version__ = "0.2.0"
|
|
20
22
|
|
|
21
23
|
__all__ = [
|
|
22
24
|
"job",
|
|
23
25
|
"run",
|
|
26
|
+
"quote",
|
|
24
27
|
"receipt",
|
|
25
28
|
"Job",
|
|
26
29
|
"Result",
|
|
27
30
|
"Receipt",
|
|
28
31
|
"Settlement",
|
|
32
|
+
"Quote",
|
|
33
|
+
"VenueQuote",
|
|
29
34
|
"Status",
|
|
30
35
|
"Venue",
|
|
31
36
|
"BatchState",
|
|
32
37
|
"parse_deadline",
|
|
33
38
|
"seconds_until",
|
|
34
39
|
"default_venues",
|
|
40
|
+
"format_usd",
|
|
35
41
|
"prices",
|
|
36
42
|
"__version__",
|
|
37
43
|
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""``python -m offpeak`` — the quote desk, from a terminal.
|
|
2
|
+
|
|
3
|
+
python -m offpeak quote --model gpt-5.6-luna --input-tokens 800 \\
|
|
4
|
+
--output-tokens 200 --jobs 5000
|
|
5
|
+
|
|
6
|
+
Prices against the bundled sheet only. No API calls, no key required.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .job import Job
|
|
16
|
+
from .quote import quote
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _quote_cmd(a: argparse.Namespace) -> int:
|
|
20
|
+
jobs = [
|
|
21
|
+
Job(
|
|
22
|
+
model=a.model,
|
|
23
|
+
messages=[],
|
|
24
|
+
metadata={"input_tokens": a.input_tokens, "output_tokens": a.output_tokens},
|
|
25
|
+
)
|
|
26
|
+
for _ in range(a.jobs)
|
|
27
|
+
]
|
|
28
|
+
try:
|
|
29
|
+
print(quote(jobs, a.deadline))
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
32
|
+
return 2
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
ap = argparse.ArgumentParser(prog="offpeak", description=__doc__.splitlines()[0])
|
|
38
|
+
ap.add_argument("--version", action="version", version=f"offpeak {__version__}")
|
|
39
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
q = sub.add_parser("quote", help="price a batch of jobs against a deadline")
|
|
42
|
+
q.add_argument("--model", required=True, help="e.g. gpt-5.6-luna, claude-haiku-4-5")
|
|
43
|
+
q.add_argument("--input-tokens", type=int, required=True, help="per job")
|
|
44
|
+
q.add_argument("--output-tokens", type=int, required=True, help="per job")
|
|
45
|
+
q.add_argument("--jobs", type=int, default=1, help="how many jobs (default 1)")
|
|
46
|
+
q.add_argument(
|
|
47
|
+
"--deadline",
|
|
48
|
+
default="24h",
|
|
49
|
+
help='when it must be done: "24h", "06:00", an ISO timestamp (default 24h)',
|
|
50
|
+
)
|
|
51
|
+
q.set_defaults(func=_quote_cmd)
|
|
52
|
+
|
|
53
|
+
a = ap.parse_args(argv)
|
|
54
|
+
if a.jobs < 1:
|
|
55
|
+
ap.error("--jobs must be at least 1")
|
|
56
|
+
if a.input_tokens < 0 or a.output_tokens < 0:
|
|
57
|
+
ap.error("token counts cannot be negative")
|
|
58
|
+
return a.func(a)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
raise SystemExit(main())
|
|
@@ -8,14 +8,13 @@ own-GPU off-peak windows, and carbon-aware scheduling on the same interface.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
import math
|
|
12
11
|
import time
|
|
13
12
|
from dataclasses import dataclass, field
|
|
14
13
|
from datetime import datetime
|
|
15
14
|
|
|
16
15
|
from .deadline import parse_deadline, seconds_until
|
|
17
16
|
from .job import Job, Receipt, Result, Status
|
|
18
|
-
from .prices import PRICE_SHEET_DATE
|
|
17
|
+
from .prices import BATCH_DISCOUNT, PRICE_SHEET_DATE, format_usd
|
|
19
18
|
from .venues.base import Venue
|
|
20
19
|
|
|
21
20
|
__all__ = ["run", "receipt", "Settlement", "default_venues"]
|
|
@@ -205,14 +204,7 @@ def _run_sync(venue: Venue, j: Job) -> Result:
|
|
|
205
204
|
return Result(job=j, error=str(exc))
|
|
206
205
|
|
|
207
206
|
|
|
208
|
-
|
|
209
|
-
"""Money for humans: 2dp once there are cents to show, more significant
|
|
210
|
-
digits below that so a sub-cent run does not settle as a column of $0.00."""
|
|
211
|
-
if amount == 0:
|
|
212
|
-
return "0.00"
|
|
213
|
-
if abs(amount) >= 0.005:
|
|
214
|
-
return f"{amount:,.2f}"
|
|
215
|
-
return f"{amount:,.{-math.floor(math.log10(abs(amount))) + 2}f}"
|
|
207
|
+
_usd = format_usd # kept as a private alias; the canonical home is prices
|
|
216
208
|
|
|
217
209
|
|
|
218
210
|
@dataclass
|
|
@@ -228,6 +220,7 @@ class Settlement:
|
|
|
228
220
|
output_tokens: int = 0
|
|
229
221
|
list_usd: float = 0.0
|
|
230
222
|
paid_usd: float = 0.0
|
|
223
|
+
left_on_table_usd: float = 0.0
|
|
231
224
|
unpriced: int = 0
|
|
232
225
|
by_venue: dict = field(default_factory=dict)
|
|
233
226
|
|
|
@@ -253,6 +246,11 @@ class Settlement:
|
|
|
253
246
|
f"captured ${_usd(self.captured_usd)} ({self.captured_pct:.1f}%)",
|
|
254
247
|
f"prices snapshot {PRICE_SHEET_DATE} — override via offpeak.prices",
|
|
255
248
|
]
|
|
249
|
+
if self.fell_back:
|
|
250
|
+
lines.append(
|
|
251
|
+
f"left ${_usd(self.left_on_table_usd)} on the table "
|
|
252
|
+
f"({self.fell_back} job(s) missed the batch tier)"
|
|
253
|
+
)
|
|
256
254
|
if self.unpriced:
|
|
257
255
|
lines.append(f"note {self.unpriced} job(s) had no price sheet entry")
|
|
258
256
|
lines.append("─" * 47)
|
|
@@ -281,4 +279,7 @@ def receipt(results: list[Result]) -> Settlement:
|
|
|
281
279
|
else:
|
|
282
280
|
settlement.list_usd += r.list_usd
|
|
283
281
|
settlement.paid_usd += r.paid_usd
|
|
282
|
+
if r.fell_back:
|
|
283
|
+
# The spread this job would have captured had the batch held.
|
|
284
|
+
settlement.left_on_table_usd += r.list_usd * (1 - BATCH_DISCOUNT)
|
|
284
285
|
return settlement
|
|
@@ -8,7 +8,8 @@ Accepted forms:
|
|
|
8
8
|
- ``"06:00"`` — the next occurrence of that wall-clock time (today if it is
|
|
9
9
|
still ahead, otherwise tomorrow). This is the canonical overnight form.
|
|
10
10
|
- ``"6h"``, ``"90m"``, ``"45s"``, ``"2d"`` — relative to now.
|
|
11
|
-
- ISO 8601 strings — ``"2026-08-21T06:00:00-07:00"
|
|
11
|
+
- ISO 8601 strings — ``"2026-08-21T06:00:00-07:00"``, including the
|
|
12
|
+
``Z`` (UTC) suffix on every supported Python.
|
|
12
13
|
|
|
13
14
|
All deadlines resolve to an aware :class:`datetime.datetime`. See SPEC.md for
|
|
14
15
|
the full semantics.
|
|
@@ -76,8 +77,13 @@ def _parse(value: object, now: datetime) -> datetime:
|
|
|
76
77
|
if candidate <= now:
|
|
77
78
|
candidate += timedelta(days=1)
|
|
78
79
|
return candidate
|
|
80
|
+
text = value.strip()
|
|
81
|
+
# Python 3.11 taught fromisoformat to read a trailing "Z"; 3.10 did not,
|
|
82
|
+
# and Z is the ISO form most timestamps in the wild actually use.
|
|
83
|
+
if text.endswith(("Z", "z")):
|
|
84
|
+
text = f"{text[:-1]}+00:00"
|
|
79
85
|
try:
|
|
80
|
-
parsed = datetime.fromisoformat(
|
|
86
|
+
parsed = datetime.fromisoformat(text)
|
|
81
87
|
except ValueError:
|
|
82
88
|
raise ValueError(f"unrecognized deadline: {value!r}") from None
|
|
83
89
|
return parsed if parsed.tzinfo else parsed.astimezone()
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from enum import Enum
|
|
9
9
|
|
|
10
|
-
from .prices import batch_cost_usd, list_cost_usd
|
|
10
|
+
from .prices import batch_cost_usd, format_usd, list_cost_usd
|
|
11
11
|
|
|
12
12
|
__all__ = ["Job", "Result", "Receipt", "Status", "job"]
|
|
13
13
|
|
|
@@ -93,6 +93,21 @@ class Receipt:
|
|
|
93
93
|
return None
|
|
94
94
|
return self.list_usd - self.paid_usd
|
|
95
95
|
|
|
96
|
+
def __str__(self) -> str:
|
|
97
|
+
"""One line, in money you can actually read.
|
|
98
|
+
|
|
99
|
+
The float properties above stay floats — this is the rendering, so a
|
|
100
|
+
sub-cent job reports what it cost instead of $0.00.
|
|
101
|
+
"""
|
|
102
|
+
where = f"{self.venue} {self.model}"
|
|
103
|
+
if self.fell_back:
|
|
104
|
+
where += " (sync fallback)"
|
|
105
|
+
return (
|
|
106
|
+
f"{where}: {self.input_tokens:,} in · {self.output_tokens:,} out · "
|
|
107
|
+
f"list ${format_usd(self.list_usd)} · paid ${format_usd(self.paid_usd)} · "
|
|
108
|
+
f"captured ${format_usd(self.spread_usd)}"
|
|
109
|
+
)
|
|
110
|
+
|
|
96
111
|
|
|
97
112
|
@dataclass
|
|
98
113
|
class Result:
|
|
@@ -12,9 +12,12 @@ list, which is what :data:`BATCH_DISCOUNT` encodes.
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import math
|
|
16
|
+
|
|
15
17
|
__all__ = [
|
|
16
18
|
"PRICE_SHEET_DATE",
|
|
17
19
|
"BATCH_DISCOUNT",
|
|
20
|
+
"format_usd",
|
|
18
21
|
"register_price",
|
|
19
22
|
"get_price",
|
|
20
23
|
"list_cost_usd",
|
|
@@ -77,3 +80,19 @@ def list_cost_usd(model: str, input_tokens: int, output_tokens: int) -> float |
|
|
|
77
80
|
def batch_cost_usd(model: str, input_tokens: int, output_tokens: int) -> float | None:
|
|
78
81
|
cost = list_cost_usd(model, input_tokens, output_tokens)
|
|
79
82
|
return None if cost is None else cost * BATCH_DISCOUNT
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_usd(amount: float | None) -> str:
|
|
86
|
+
"""Money for humans: 2dp once there are cents to show, more significant
|
|
87
|
+
digits below that so a sub-cent job does not settle as a column of $0.00.
|
|
88
|
+
|
|
89
|
+
``None`` (an unpriced model) renders as an em dash, never as zero — a price
|
|
90
|
+
we do not know is not a price of nothing.
|
|
91
|
+
"""
|
|
92
|
+
if amount is None:
|
|
93
|
+
return "—"
|
|
94
|
+
if amount == 0:
|
|
95
|
+
return "0.00"
|
|
96
|
+
if abs(amount) >= 0.005:
|
|
97
|
+
return f"{amount:,.2f}"
|
|
98
|
+
return f"{amount:,.{-math.floor(math.log10(abs(amount))) + 2}f}"
|