ergoda 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.
- ergoda-0.1.0/.gitignore +56 -0
- ergoda-0.1.0/LICENSE.md +105 -0
- ergoda-0.1.0/PKG-INFO +142 -0
- ergoda-0.1.0/docs/pypi.md +109 -0
- ergoda-0.1.0/hatch_build.py +184 -0
- ergoda-0.1.0/pyproject.toml +136 -0
- ergoda-0.1.0/src/ergoda/__init__.py +24 -0
- ergoda-0.1.0/src/ergoda/analyse/__init__.py +0 -0
- ergoda-0.1.0/src/ergoda/analyse/cacheprefix.py +312 -0
- ergoda-0.1.0/src/ergoda/analyse/calculator.py +187 -0
- ergoda-0.1.0/src/ergoda/analyse/catalogue.py +514 -0
- ergoda-0.1.0/src/ergoda/analyse/compare.py +169 -0
- ergoda-0.1.0/src/ergoda/analyse/derive_contracts.py +418 -0
- ergoda-0.1.0/src/ergoda/analyse/economics.py +559 -0
- ergoda-0.1.0/src/ergoda/analyse/engine.py +638 -0
- ergoda-0.1.0/src/ergoda/analyse/equivalence.py +273 -0
- ergoda-0.1.0/src/ergoda/analyse/exclusions.py +232 -0
- ergoda-0.1.0/src/ergoda/analyse/failures.py +283 -0
- ergoda-0.1.0/src/ergoda/analyse/groundtruth.py +342 -0
- ergoda-0.1.0/src/ergoda/analyse/judge.py +91 -0
- ergoda-0.1.0/src/ergoda/analyse/modes.py +448 -0
- ergoda-0.1.0/src/ergoda/analyse/modes_cli.py +339 -0
- ergoda-0.1.0/src/ergoda/analyse/pricing.py +137 -0
- ergoda-0.1.0/src/ergoda/analyse/prose_judge.py +92 -0
- ergoda-0.1.0/src/ergoda/analyse/render.py +178 -0
- ergoda-0.1.0/src/ergoda/analyse/report.py +807 -0
- ergoda-0.1.0/src/ergoda/analyse/reversibility.py +1374 -0
- ergoda-0.1.0/src/ergoda/analyse/stability.py +167 -0
- ergoda-0.1.0/src/ergoda/analyse/trajectory.py +561 -0
- ergoda-0.1.0/src/ergoda/cli.py +904 -0
- ergoda-0.1.0/src/ergoda/cluster.py +461 -0
- ergoda-0.1.0/src/ergoda/commitments.py +1251 -0
- ergoda-0.1.0/src/ergoda/costs.py +217 -0
- ergoda-0.1.0/src/ergoda/dataplane/__init__.py +3 -0
- ergoda-0.1.0/src/ergoda/dataplane/admission.py +149 -0
- ergoda-0.1.0/src/ergoda/dataplane/anomaly.py +48 -0
- ergoda-0.1.0/src/ergoda/dataplane/app.py +544 -0
- ergoda-0.1.0/src/ergoda/dataplane/audit.py +48 -0
- ergoda-0.1.0/src/ergoda/dataplane/cli.py +448 -0
- ergoda-0.1.0/src/ergoda/dataplane/context.py +104 -0
- ergoda-0.1.0/src/ergoda/dataplane/exporters.py +257 -0
- ergoda-0.1.0/src/ergoda/dataplane/governed.py +1112 -0
- ergoda-0.1.0/src/ergoda/dataplane/limits.py +301 -0
- ergoda-0.1.0/src/ergoda/dataplane/metrics.py +458 -0
- ergoda-0.1.0/src/ergoda/dataplane/observe.py +628 -0
- ergoda-0.1.0/src/ergoda/dataplane/observe_cli.py +168 -0
- ergoda-0.1.0/src/ergoda/dataplane/policy.py +90 -0
- ergoda-0.1.0/src/ergoda/dataplane/quota.py +75 -0
- ergoda-0.1.0/src/ergoda/dataplane/reload.py +457 -0
- ergoda-0.1.0/src/ergoda/dataplane/routing.py +200 -0
- ergoda-0.1.0/src/ergoda/dataplane/shapes/__init__.py +208 -0
- ergoda-0.1.0/src/ergoda/dataplane/shapes/anthropic.py +302 -0
- ergoda-0.1.0/src/ergoda/dataplane/shapes/openai.py +193 -0
- ergoda-0.1.0/src/ergoda/dataplane/state.py +266 -0
- ergoda-0.1.0/src/ergoda/extras.py +43 -0
- ergoda-0.1.0/src/ergoda/golden/__init__.py +22 -0
- ergoda-0.1.0/src/ergoda/golden/cli.py +252 -0
- ergoda-0.1.0/src/ergoda/golden/model.py +151 -0
- ergoda-0.1.0/src/ergoda/golden/runner.py +298 -0
- ergoda-0.1.0/src/ergoda/grading.py +316 -0
- ergoda-0.1.0/src/ergoda/inference/__init__.py +58 -0
- ergoda-0.1.0/src/ergoda/inference/bootstrap.py +479 -0
- ergoda-0.1.0/src/ergoda/inference/conformal.py +343 -0
- ergoda-0.1.0/src/ergoda/inference/credibility.py +123 -0
- ergoda-0.1.0/src/ergoda/inference/intervals.py +44 -0
- ergoda-0.1.0/src/ergoda/inference/ope.py +196 -0
- ergoda-0.1.0/src/ergoda/inference/power.py +101 -0
- ergoda-0.1.0/src/ergoda/inference/ppi.py +264 -0
- ergoda-0.1.0/src/ergoda/inference/sequential.py +344 -0
- ergoda-0.1.0/src/ergoda/inference/trajectory.py +256 -0
- ergoda-0.1.0/src/ergoda/jsonl.py +32 -0
- ergoda-0.1.0/src/ergoda/llm.py +791 -0
- ergoda-0.1.0/src/ergoda/parallel.py +57 -0
- ergoda-0.1.0/src/ergoda/preflight.py +788 -0
- ergoda-0.1.0/src/ergoda/providers/__init__.py +107 -0
- ergoda-0.1.0/src/ergoda/providers/anthropic.py +342 -0
- ergoda-0.1.0/src/ergoda/providers/batch.py +215 -0
- ergoda-0.1.0/src/ergoda/providers/evaluation.py +316 -0
- ergoda-0.1.0/src/ergoda/providers/gemini.py +325 -0
- ergoda-0.1.0/src/ergoda/providers/http.py +165 -0
- ergoda-0.1.0/src/ergoda/providers/openai.py +165 -0
- ergoda-0.1.0/src/ergoda/providers/params.py +201 -0
- ergoda-0.1.0/src/ergoda/providers/pricing.py +284 -0
- ergoda-0.1.0/src/ergoda/providers/registry.py +137 -0
- ergoda-0.1.0/src/ergoda/providers/shape.py +136 -0
- ergoda-0.1.0/src/ergoda/published.py +125 -0
- ergoda-0.1.0/src/ergoda/sample/__init__.py +1 -0
- ergoda-0.1.0/src/ergoda/sample/traces.jsonl +114 -0
- ergoda-0.1.0/src/ergoda/schemas.py +549 -0
- ergoda-0.1.0/src/ergoda/sdk/__init__.py +153 -0
- ergoda-0.1.0/src/ergoda/sdk/assurance.py +293 -0
- ergoda-0.1.0/src/ergoda/sdk/classify.py +115 -0
- ergoda-0.1.0/src/ergoda/sdk/cli.py +717 -0
- ergoda-0.1.0/src/ergoda/sdk/client.py +5239 -0
- ergoda-0.1.0/src/ergoda/sdk/confidence.py +527 -0
- ergoda-0.1.0/src/ergoda/sdk/declarations.py +593 -0
- ergoda-0.1.0/src/ergoda/sdk/evidence.py +265 -0
- ergoda-0.1.0/src/ergoda/sdk/explain.py +174 -0
- ergoda-0.1.0/src/ergoda/sdk/gate.py +2925 -0
- ergoda-0.1.0/src/ergoda/sdk/phase.py +473 -0
- ergoda-0.1.0/src/ergoda/sdk/recording.py +300 -0
- ergoda-0.1.0/src/ergoda/sdk/spend.py +627 -0
- ergoda-0.1.0/src/ergoda/sdk/status.py +267 -0
- ergoda-0.1.0/src/ergoda/sdk/streams.py +347 -0
- ergoda-0.1.0/src/ergoda/serving.py +671 -0
- ergoda-0.1.0/src/ergoda/traces/__init__.py +0 -0
- ergoda-0.1.0/src/ergoda/traces/model.py +177 -0
- ergoda-0.1.0/src/ergoda/traces/parse.py +723 -0
- ergoda-0.1.0/src/ergoda/transports/__init__.py +96 -0
- ergoda-0.1.0/src/ergoda/wire.py +2766 -0
ergoda-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# OS
|
|
2
|
+
.DS_Store
|
|
3
|
+
Thumbs.db
|
|
4
|
+
|
|
5
|
+
# Editors
|
|
6
|
+
.vscode/
|
|
7
|
+
.idea/
|
|
8
|
+
*.swp
|
|
9
|
+
|
|
10
|
+
# Env / secrets
|
|
11
|
+
.env
|
|
12
|
+
.env.*
|
|
13
|
+
!.env.example
|
|
14
|
+
*.pem
|
|
15
|
+
*.key
|
|
16
|
+
|
|
17
|
+
# Deps / build
|
|
18
|
+
node_modules/
|
|
19
|
+
dist/
|
|
20
|
+
build/
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.py[cod]
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
|
|
26
|
+
# Data / scratch
|
|
27
|
+
*.log
|
|
28
|
+
tmp/
|
|
29
|
+
scratch/
|
|
30
|
+
/traces/
|
|
31
|
+
|
|
32
|
+
# Local databases
|
|
33
|
+
*.db
|
|
34
|
+
|
|
35
|
+
# Replay cache: raw model responses. On a real customer trace this holds
|
|
36
|
+
# customer payloads — it must never be committed.
|
|
37
|
+
.ergoda-cache/
|
|
38
|
+
*.sqlite
|
|
39
|
+
*.sqlite-shm
|
|
40
|
+
*.sqlite-wal
|
|
41
|
+
|
|
42
|
+
# DTap-Bench working set: 50 MB, rebuilt deterministically by experiments/dtap_index.py
|
|
43
|
+
experiments/dtap_index.json
|
|
44
|
+
# Third-party dataset cache (CC-BY-4.0), rebuilt by experiments/compounding.py
|
|
45
|
+
experiments/trajectories.json
|
|
46
|
+
|
|
47
|
+
# Coverage artefacts (the CI gate reports; nothing here is worth committing)
|
|
48
|
+
.coverage
|
|
49
|
+
.coverage.*
|
|
50
|
+
coverage.xml
|
|
51
|
+
htmlcov/
|
|
52
|
+
experiments/taubench_dry.jsonl
|
|
53
|
+
|
|
54
|
+
# Vercel: the linked project, and the role URLs `hosted roles` writes (secrets).
|
|
55
|
+
.vercel/
|
|
56
|
+
.ergoda-roles.env
|
ergoda-0.1.0/LICENSE.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, ALv2 Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-ALv2
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2026 Maffen Oy
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the Apache License, Version 2.0 that is effective on the second anniversary of
|
|
91
|
+
the date we make the Software available. On or after that date, you may use the
|
|
92
|
+
Software under the Apache License, Version 2.0, in which case the following
|
|
93
|
+
will apply:
|
|
94
|
+
|
|
95
|
+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
|
96
|
+
this file except in compliance with the License.
|
|
97
|
+
|
|
98
|
+
You may obtain a copy of the License at
|
|
99
|
+
|
|
100
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
101
|
+
|
|
102
|
+
Unless required by applicable law or agreed to in writing, software distributed
|
|
103
|
+
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
|
+
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
|
+
specific language governing permissions and limitations under the License.
|
ergoda-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ergoda
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure which steps of your agent can run on a cheaper model, and keep measuring
|
|
5
|
+
Author: Ergoda
|
|
6
|
+
License-Expression: FSL-1.1-ALv2
|
|
7
|
+
License-File: LICENSE.md
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: click>=8.1
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pydantic>=2.7
|
|
12
|
+
Requires-Dist: pyyaml>=6.0
|
|
13
|
+
Requires-Dist: rich>=13.7
|
|
14
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
15
|
+
Provides-Extra: all
|
|
16
|
+
Requires-Dist: fastapi>=0.111; extra == 'all'
|
|
17
|
+
Requires-Dist: litellm<2,>=1.40; extra == 'all'
|
|
18
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'all'
|
|
19
|
+
Requires-Dist: python-multipart>=0.0.9; extra == 'all'
|
|
20
|
+
Requires-Dist: uvicorn>=0.30; extra == 'all'
|
|
21
|
+
Provides-Extra: hosted
|
|
22
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'hosted'
|
|
23
|
+
Requires-Dist: sentry-sdk>=2.0; extra == 'hosted'
|
|
24
|
+
Provides-Extra: litellm
|
|
25
|
+
Requires-Dist: litellm<2,>=1.40; extra == 'litellm'
|
|
26
|
+
Provides-Extra: postgres
|
|
27
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
28
|
+
Provides-Extra: server
|
|
29
|
+
Requires-Dist: fastapi>=0.111; extra == 'server'
|
|
30
|
+
Requires-Dist: python-multipart>=0.0.9; extra == 'server'
|
|
31
|
+
Requires-Dist: uvicorn>=0.30; extra == 'server'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# ergoda
|
|
35
|
+
|
|
36
|
+
Measures which steps of your agent can run on a cheaper model, and keeps
|
|
37
|
+
measuring once they do. Your code keeps calling your own provider with your
|
|
38
|
+
own key. Ergoda decides which model a step uses, and anything it cannot
|
|
39
|
+
decide goes to the model you would have called anyway.
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip install ergoda
|
|
43
|
+
ergoda onboard --sample # a first report on bundled sample data, offline
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The ten-minute path is the [quickstart](https://ergoda.com/docs/quickstart/);
|
|
47
|
+
every term is defined in [concepts](https://ergoda.com/docs/concepts/).
|
|
48
|
+
|
|
49
|
+
## Start: measure before anything moves
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
ergoda init --frontier anthropic/claude-opus-5 --step resolve_ticket
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This writes a `routes.yaml` that routes nothing: every step keeps serving
|
|
56
|
+
the model your code already asks for. It prints the two lines to paste:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from ergoda.sdk import Client
|
|
60
|
+
|
|
61
|
+
ergoda = Client.from_env()
|
|
62
|
+
create = ergoda.wrap({"anthropic": client.messages.create}, step="resolve_ticket")
|
|
63
|
+
|
|
64
|
+
# unchanged from here: same arguments, same response object
|
|
65
|
+
response = create(model="claude-opus-5", max_tokens=1024, messages=msgs, tools=TOOLS)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Add `--control-plane https://api.ergoda.com --email you@company.com` to get
|
|
69
|
+
a site key, so the metrics reach a control plane where they accumulate, and a
|
|
70
|
+
dashboard at https://app.ergoda.com/ (sign in with the email address you
|
|
71
|
+
signed up with; until email sign-in is switched on, the page links a sign-in
|
|
72
|
+
with the site key). Only derived
|
|
73
|
+
metrics are sent (hashes, counts, token totals). Prompts and responses stay in
|
|
74
|
+
your process. The control plane is a hosted service: it is not in this
|
|
75
|
+
package, and running your own is not offered.
|
|
76
|
+
|
|
77
|
+
## Then: find what can move
|
|
78
|
+
|
|
79
|
+
No trace export yet? Wrap your create call as
|
|
80
|
+
`record(client.messages.create)` (`from ergoda import record`) and it writes
|
|
81
|
+
one to `traces.jsonl` on your own disk as your agent runs. Langfuse, LangSmith
|
|
82
|
+
and OpenTelemetry exports are read as they are.
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
ergoda onboard traces.jsonl --step resolve_ticket \
|
|
86
|
+
--serve anthropic/claude-haiku-4-5 --escalate-to anthropic/claude-opus-5
|
|
87
|
+
ergoda analyse traces.jsonl --model anthropic/claude-haiku-4-5 --html report.html
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`onboard` checks that the export can be replayed, lists the tools whose
|
|
91
|
+
effects can be undone, and prints the policy it would write. It writes
|
|
92
|
+
nothing until a named person approves: add `--approved-by "Your Name"`.
|
|
93
|
+
`analyse` replays your traces against the cheaper model with your own key and
|
|
94
|
+
reports agreement per step with an interval. Without that key it stops and
|
|
95
|
+
names the variable; a replay the provider refuses is counted as a failure,
|
|
96
|
+
never as a disagreement.
|
|
97
|
+
|
|
98
|
+
## What it will and will not do
|
|
99
|
+
|
|
100
|
+
- **Doubt goes to the expensive model.** An unknown step, an unreachable
|
|
101
|
+
control plane, a changed prompt or a refused proposal all serve your own
|
|
102
|
+
frontier model.
|
|
103
|
+
- **A route to a cheaper model starts with a person's name on the record.**
|
|
104
|
+
After that, autopilot may loosen an approved route's checks where your own
|
|
105
|
+
traffic shows at most a 2% added failure rate at 95% confidence. That
|
|
106
|
+
limit is a published default, not one a person on your team accepted, so
|
|
107
|
+
its moves are recorded as `ergoda:default`. You can switch it off for your
|
|
108
|
+
site on the dashboard's Switch autopilot off page, or with
|
|
109
|
+
`POST /v1/sites/SITE/autopilot` and `{"by": "your name", "enabled": false}`;
|
|
110
|
+
every move toward the cheaper model then waits for a named person. Moves it
|
|
111
|
+
already made stay until you revert them or an escalation takes them back;
|
|
112
|
+
only a route with no traffic for 90 days goes back on its own. Setting a route's
|
|
113
|
+
`enabled: false` sends that step back to your frontier model.
|
|
114
|
+
- **Routing is not meant to cost more than not routing.** In the Python SDK,
|
|
115
|
+
a route whose spend passes what the frontier model alone would have cost is
|
|
116
|
+
pinned back to the frontier. The TypeScript SDK and the proxy do not
|
|
117
|
+
enforce this yet.
|
|
118
|
+
- **The numbers are estimates with intervals.** Agreement is measured on your
|
|
119
|
+
traffic and reported with its bound. Parity is about task completion; refusal
|
|
120
|
+
behaviour is not measured.
|
|
121
|
+
|
|
122
|
+
## Latency
|
|
123
|
+
|
|
124
|
+
The decision is in-process, with no network hop on the request path, and
|
|
125
|
+
takes about a millisecond. A step the cheaper model gets wrong is redone by
|
|
126
|
+
the frontier model, so that step makes two calls in a row. Shipped
|
|
127
|
+
with your deployment, `routes.yaml` is in force from the first call, and the
|
|
128
|
+
control plane is consulted behind it. In a Lambda or Cloud Run handler, build
|
|
129
|
+
the client at module scope and wrap the body in `with ergoda.request():` so
|
|
130
|
+
queued metrics are delivered before the process is frozen.
|
|
131
|
+
|
|
132
|
+
Python 3.11+. OpenAI, Anthropic, Gemini and Bedrock request and tool-call
|
|
133
|
+
shapes are read natively. Other providers go through the optional `litellm`
|
|
134
|
+
extra. `pip install 'ergoda[server]'` adds what the proxy needs, for agents
|
|
135
|
+
that reach their provider through a base URL (`ergoda dataplane serve`).
|
|
136
|
+
|
|
137
|
+
Documentation: https://ergoda.com/docs/quickstart/
|
|
138
|
+
|
|
139
|
+
Licence: [FSL-1.1-ALv2](https://fsl.software/), source-available. Use it for
|
|
140
|
+
anything, in production and commercially, except a product or service that
|
|
141
|
+
competes with Ergoda; each version becomes Apache 2.0 two years after it is
|
|
142
|
+
published.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# ergoda
|
|
2
|
+
|
|
3
|
+
Measures which steps of your agent can run on a cheaper model, and keeps
|
|
4
|
+
measuring once they do. Your code keeps calling your own provider with your
|
|
5
|
+
own key. Ergoda decides which model a step uses, and anything it cannot
|
|
6
|
+
decide goes to the model you would have called anyway.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install ergoda
|
|
10
|
+
ergoda onboard --sample # a first report on bundled sample data, offline
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The ten-minute path is the [quickstart](https://ergoda.com/docs/quickstart/);
|
|
14
|
+
every term is defined in [concepts](https://ergoda.com/docs/concepts/).
|
|
15
|
+
|
|
16
|
+
## Start: measure before anything moves
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
ergoda init --frontier anthropic/claude-opus-5 --step resolve_ticket
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This writes a `routes.yaml` that routes nothing: every step keeps serving
|
|
23
|
+
the model your code already asks for. It prints the two lines to paste:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from ergoda.sdk import Client
|
|
27
|
+
|
|
28
|
+
ergoda = Client.from_env()
|
|
29
|
+
create = ergoda.wrap({"anthropic": client.messages.create}, step="resolve_ticket")
|
|
30
|
+
|
|
31
|
+
# unchanged from here: same arguments, same response object
|
|
32
|
+
response = create(model="claude-opus-5", max_tokens=1024, messages=msgs, tools=TOOLS)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Add `--control-plane https://api.ergoda.com --email you@company.com` to get
|
|
36
|
+
a site key, so the metrics reach a control plane where they accumulate, and a
|
|
37
|
+
dashboard at https://app.ergoda.com/ (sign in with the email address you
|
|
38
|
+
signed up with; until email sign-in is switched on, the page links a sign-in
|
|
39
|
+
with the site key). Only derived
|
|
40
|
+
metrics are sent (hashes, counts, token totals). Prompts and responses stay in
|
|
41
|
+
your process. The control plane is a hosted service: it is not in this
|
|
42
|
+
package, and running your own is not offered.
|
|
43
|
+
|
|
44
|
+
## Then: find what can move
|
|
45
|
+
|
|
46
|
+
No trace export yet? Wrap your create call as
|
|
47
|
+
`record(client.messages.create)` (`from ergoda import record`) and it writes
|
|
48
|
+
one to `traces.jsonl` on your own disk as your agent runs. Langfuse, LangSmith
|
|
49
|
+
and OpenTelemetry exports are read as they are.
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
ergoda onboard traces.jsonl --step resolve_ticket \
|
|
53
|
+
--serve anthropic/claude-haiku-4-5 --escalate-to anthropic/claude-opus-5
|
|
54
|
+
ergoda analyse traces.jsonl --model anthropic/claude-haiku-4-5 --html report.html
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`onboard` checks that the export can be replayed, lists the tools whose
|
|
58
|
+
effects can be undone, and prints the policy it would write. It writes
|
|
59
|
+
nothing until a named person approves: add `--approved-by "Your Name"`.
|
|
60
|
+
`analyse` replays your traces against the cheaper model with your own key and
|
|
61
|
+
reports agreement per step with an interval. Without that key it stops and
|
|
62
|
+
names the variable; a replay the provider refuses is counted as a failure,
|
|
63
|
+
never as a disagreement.
|
|
64
|
+
|
|
65
|
+
## What it will and will not do
|
|
66
|
+
|
|
67
|
+
- **Doubt goes to the expensive model.** An unknown step, an unreachable
|
|
68
|
+
control plane, a changed prompt or a refused proposal all serve your own
|
|
69
|
+
frontier model.
|
|
70
|
+
- **A route to a cheaper model starts with a person's name on the record.**
|
|
71
|
+
After that, autopilot may loosen an approved route's checks where your own
|
|
72
|
+
traffic shows at most a 2% added failure rate at 95% confidence. That
|
|
73
|
+
limit is a published default, not one a person on your team accepted, so
|
|
74
|
+
its moves are recorded as `ergoda:default`. You can switch it off for your
|
|
75
|
+
site on the dashboard's Switch autopilot off page, or with
|
|
76
|
+
`POST /v1/sites/SITE/autopilot` and `{"by": "your name", "enabled": false}`;
|
|
77
|
+
every move toward the cheaper model then waits for a named person. Moves it
|
|
78
|
+
already made stay until you revert them or an escalation takes them back;
|
|
79
|
+
only a route with no traffic for 90 days goes back on its own. Setting a route's
|
|
80
|
+
`enabled: false` sends that step back to your frontier model.
|
|
81
|
+
- **Routing is not meant to cost more than not routing.** In the Python SDK,
|
|
82
|
+
a route whose spend passes what the frontier model alone would have cost is
|
|
83
|
+
pinned back to the frontier. The TypeScript SDK and the proxy do not
|
|
84
|
+
enforce this yet.
|
|
85
|
+
- **The numbers are estimates with intervals.** Agreement is measured on your
|
|
86
|
+
traffic and reported with its bound. Parity is about task completion; refusal
|
|
87
|
+
behaviour is not measured.
|
|
88
|
+
|
|
89
|
+
## Latency
|
|
90
|
+
|
|
91
|
+
The decision is in-process, with no network hop on the request path, and
|
|
92
|
+
takes about a millisecond. A step the cheaper model gets wrong is redone by
|
|
93
|
+
the frontier model, so that step makes two calls in a row. Shipped
|
|
94
|
+
with your deployment, `routes.yaml` is in force from the first call, and the
|
|
95
|
+
control plane is consulted behind it. In a Lambda or Cloud Run handler, build
|
|
96
|
+
the client at module scope and wrap the body in `with ergoda.request():` so
|
|
97
|
+
queued metrics are delivered before the process is frozen.
|
|
98
|
+
|
|
99
|
+
Python 3.11+. OpenAI, Anthropic, Gemini and Bedrock request and tool-call
|
|
100
|
+
shapes are read natively. Other providers go through the optional `litellm`
|
|
101
|
+
extra. `pip install 'ergoda[server]'` adds what the proxy needs, for agents
|
|
102
|
+
that reach their provider through a base URL (`ergoda dataplane serve`).
|
|
103
|
+
|
|
104
|
+
Documentation: https://ergoda.com/docs/quickstart/
|
|
105
|
+
|
|
106
|
+
Licence: [FSL-1.1-ALv2](https://fsl.software/), source-available. Use it for
|
|
107
|
+
anything, in production and commercially, except a product or service that
|
|
108
|
+
competes with Ergoda; each version becomes Apache 2.0 two years after it is
|
|
109
|
+
published.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Which build of ``ergoda`` this is: the public one, or the full one.
|
|
2
|
+
|
|
3
|
+
The public wheel and sdist, the ones bound for PyPI, carry the SDK, the
|
|
4
|
+
analyser and the data plane. They leave out ``PRIVATE``: the control plane
|
|
5
|
+
(the metered, hosted product: billing, autopilot, the licensing statistics),
|
|
6
|
+
the synthetic worlds and the harness that runs them, the benchmark fixtures,
|
|
7
|
+
the hosted no-signup web tool, and the Codex CLI transport, which drives a
|
|
8
|
+
ChatGPT subscription and is research tooling. Publishing to an index cannot
|
|
9
|
+
be undone, so the exclusion is the DEFAULT: it is written in
|
|
10
|
+
``pyproject.toml`` as a static ``exclude`` and holds even if this hook never
|
|
11
|
+
ran.
|
|
12
|
+
|
|
13
|
+
``ERGODA_WHEEL=full`` builds everything, as the hosted deploys and the
|
|
14
|
+
control-plane image need. That name is a contract with those builds; do not
|
|
15
|
+
rename it. The full build adds ``PRIVATE`` back with ``force_include``, which
|
|
16
|
+
is the only way hatchling lets a hook add files to a target; the public
|
|
17
|
+
build checks that the static exclusion still names every path here, so the
|
|
18
|
+
two lists cannot drift apart without a build failing.
|
|
19
|
+
|
|
20
|
+
The full build is also unpublishable by construction: its version carries
|
|
21
|
+
the local segment ``+full`` (``0.1.0+full``), which PyPI refuses to accept,
|
|
22
|
+
so a shell that still has ``ERGODA_WHEEL=full`` set cannot publish the
|
|
23
|
+
control plane by accident, and its files are named differently from the
|
|
24
|
+
public ones. ``scripts/check_dist.py`` reads a ``dist/`` directory back
|
|
25
|
+
before a publish and fails on either (``docs/usage.md``, "Development").
|
|
26
|
+
That is why the version is ``dynamic`` in ``pyproject.toml``: this file's
|
|
27
|
+
metadata hook sets it, from ``__version__`` in ``src/ergoda/__init__.py``.
|
|
28
|
+
|
|
29
|
+
``force_include`` bypasses hatch's own file selection, so the full build
|
|
30
|
+
adds the private paths back file by file (``_selected``), and only files an
|
|
31
|
+
ALLOWLIST names (``PRIVATE_FILES``: the modules and the licence). It used to
|
|
32
|
+
take every file ``.gitignore`` did not name, which kept a ``.ergoda-roles.env``
|
|
33
|
+
with an owner password in it out of the wheel only where ``.gitignore`` was
|
|
34
|
+
present -- and the Vercel CLI never uploads ``.gitignore`` (it is on its own
|
|
35
|
+
built-in ignore list, ahead of ``.vercelignore``), so on ``vercel deploy``
|
|
36
|
+
every ``.env``, ``*.pem`` and log under a private directory went into the
|
|
37
|
+
function. A file type the private directories need is added to
|
|
38
|
+
``PRIVATE_FILES`` on purpose; ``tests/test_packaging.py`` fails on a tracked
|
|
39
|
+
file there that the list does not name.
|
|
40
|
+
|
|
41
|
+
Editable installs (``uv sync``, ``pip install -e``) put ``src/`` on the path
|
|
42
|
+
and are never filtered: a development checkout always has everything.
|
|
43
|
+
|
|
44
|
+
This file ships in the sdist, because a wheel built from the sdist runs it.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import fnmatch
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
from collections.abc import Iterator
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
import pathspec
|
|
57
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
58
|
+
from hatchling.metadata.plugin.interface import MetadataHookInterface
|
|
59
|
+
|
|
60
|
+
ENV = "ERGODA_WHEEL"
|
|
61
|
+
FULL = "full"
|
|
62
|
+
# Accepted spellings of "the public build", so a typo such as `Full` fails
|
|
63
|
+
# instead of quietly building the public wheel for a deploy that needs the
|
|
64
|
+
# control plane.
|
|
65
|
+
PUBLIC = ("", "public")
|
|
66
|
+
|
|
67
|
+
# Project-root-relative. A directory drops everything under it.
|
|
68
|
+
PRIVATE = (
|
|
69
|
+
"src/ergoda/controlplane",
|
|
70
|
+
"src/ergoda/synth",
|
|
71
|
+
"src/ergoda/harness",
|
|
72
|
+
"src/ergoda/bench",
|
|
73
|
+
"src/ergoda/web",
|
|
74
|
+
"src/ergoda/transports/codex.py",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# What the full build takes from under ``PRIVATE``, by file name (fnmatch):
|
|
79
|
+
# every tracked file there is one of these. Hidden files and directories are
|
|
80
|
+
# never taken, whatever their name.
|
|
81
|
+
PRIVATE_FILES = ("*.py", "LICENSE*")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def shipped(relative: str) -> bool:
|
|
85
|
+
"""Whether the full build takes the private file at ``relative``."""
|
|
86
|
+
parts = relative.split("/")
|
|
87
|
+
return not any(part.startswith(".") for part in parts) and any(
|
|
88
|
+
fnmatch.fnmatchcase(parts[-1], pattern) for pattern in PRIVATE_FILES
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# The local version segment of the full build. PyPI rejects any version with
|
|
93
|
+
# one, which is the point: see the module docstring.
|
|
94
|
+
LOCAL = "+full"
|
|
95
|
+
VERSION_FILE = "src/ergoda/__init__.py"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def mode() -> str:
|
|
99
|
+
"""``full`` or ``public``, from the environment; anything else raises."""
|
|
100
|
+
raw = os.environ.get(ENV, "").strip()
|
|
101
|
+
if raw == FULL:
|
|
102
|
+
return FULL
|
|
103
|
+
if raw in PUBLIC:
|
|
104
|
+
return "public"
|
|
105
|
+
raise ValueError(f"{ENV}={raw!r}: set it to {FULL!r} for the full build, or leave it unset")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def package_version(root: str | Path) -> str:
|
|
109
|
+
"""``__version__`` from ``VERSION_FILE``, with ``LOCAL`` appended in the
|
|
110
|
+
full build."""
|
|
111
|
+
text = (Path(root) / VERSION_FILE).read_text(encoding="utf-8")
|
|
112
|
+
found = re.search(r'^__version__ = "([^"]+)"$', text, re.M)
|
|
113
|
+
if found is None:
|
|
114
|
+
raise ValueError(f"{VERSION_FILE} has no __version__ line")
|
|
115
|
+
return found.group(1) + (LOCAL if mode() == FULL else "")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ErgodaMetadataHook(MetadataHookInterface):
|
|
119
|
+
PLUGIN_NAME = "custom"
|
|
120
|
+
|
|
121
|
+
def update(self, metadata: dict[str, Any]) -> None:
|
|
122
|
+
metadata["version"] = package_version(self.root)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ErgodaBuildHook(BuildHookInterface):
|
|
126
|
+
PLUGIN_NAME = "custom"
|
|
127
|
+
|
|
128
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
|
129
|
+
if version == "editable":
|
|
130
|
+
return
|
|
131
|
+
root = Path(self.root)
|
|
132
|
+
if mode() == FULL:
|
|
133
|
+
missing = [path for path in PRIVATE if not (root / path).exists()]
|
|
134
|
+
if missing:
|
|
135
|
+
raise FileNotFoundError(
|
|
136
|
+
f"{ENV}={FULL} needs {', '.join(missing)}, which this source tree does "
|
|
137
|
+
"not have: it is a public sdist. Build the full package from the repository."
|
|
138
|
+
)
|
|
139
|
+
for path in self._selected(root):
|
|
140
|
+
build_data["force_include"][str(root / path)] = path
|
|
141
|
+
return
|
|
142
|
+
# The public build: the static exclusion must cover every private path.
|
|
143
|
+
leaked = [
|
|
144
|
+
path
|
|
145
|
+
for path in PRIVATE
|
|
146
|
+
if not self.build_config.path_is_excluded(
|
|
147
|
+
path if path.endswith(".py") else f"{path}/__init__.py"
|
|
148
|
+
)
|
|
149
|
+
]
|
|
150
|
+
if leaked:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"pyproject.toml's [tool.hatch.build] exclude no longer names {leaked}; "
|
|
153
|
+
"the public build would publish them"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _selected(self, root: Path) -> Iterator[str]:
|
|
157
|
+
"""Every file under ``PRIVATE`` the allowlist names (``shipped``).
|
|
158
|
+
|
|
159
|
+
What ``.gitignore`` names is left out as well where the file is
|
|
160
|
+
present, and hatch's global excludes (compiled files) always; neither
|
|
161
|
+
is what keeps a secret out, since the first is absent from a Vercel
|
|
162
|
+
upload. Pruned by directory, so an ignored or hidden one is not
|
|
163
|
+
walked."""
|
|
164
|
+
config = self.build_config
|
|
165
|
+
patterns = list(config.default_global_exclude())
|
|
166
|
+
if not config.ignore_vcs:
|
|
167
|
+
patterns += config.load_vcs_exclusion_patterns()
|
|
168
|
+
ignored = pathspec.GitIgnoreSpec.from_lines(patterns)
|
|
169
|
+
for path in PRIVATE:
|
|
170
|
+
if (root / path).is_file():
|
|
171
|
+
if shipped(path) and not ignored.match_file(path):
|
|
172
|
+
yield path
|
|
173
|
+
continue
|
|
174
|
+
for directory, subdirectories, files in os.walk(root / path):
|
|
175
|
+
relative = Path(directory).relative_to(root).as_posix()
|
|
176
|
+
subdirectories[:] = sorted(
|
|
177
|
+
d
|
|
178
|
+
for d in subdirectories
|
|
179
|
+
if not d.startswith(".") and not ignored.match_file(f"{relative}/{d}/")
|
|
180
|
+
)
|
|
181
|
+
for name in sorted(files):
|
|
182
|
+
file = f"{relative}/{name}"
|
|
183
|
+
if shipped(file) and not ignored.match_file(file):
|
|
184
|
+
yield file
|