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.
Files changed (110) hide show
  1. ergoda-0.1.0/.gitignore +56 -0
  2. ergoda-0.1.0/LICENSE.md +105 -0
  3. ergoda-0.1.0/PKG-INFO +142 -0
  4. ergoda-0.1.0/docs/pypi.md +109 -0
  5. ergoda-0.1.0/hatch_build.py +184 -0
  6. ergoda-0.1.0/pyproject.toml +136 -0
  7. ergoda-0.1.0/src/ergoda/__init__.py +24 -0
  8. ergoda-0.1.0/src/ergoda/analyse/__init__.py +0 -0
  9. ergoda-0.1.0/src/ergoda/analyse/cacheprefix.py +312 -0
  10. ergoda-0.1.0/src/ergoda/analyse/calculator.py +187 -0
  11. ergoda-0.1.0/src/ergoda/analyse/catalogue.py +514 -0
  12. ergoda-0.1.0/src/ergoda/analyse/compare.py +169 -0
  13. ergoda-0.1.0/src/ergoda/analyse/derive_contracts.py +418 -0
  14. ergoda-0.1.0/src/ergoda/analyse/economics.py +559 -0
  15. ergoda-0.1.0/src/ergoda/analyse/engine.py +638 -0
  16. ergoda-0.1.0/src/ergoda/analyse/equivalence.py +273 -0
  17. ergoda-0.1.0/src/ergoda/analyse/exclusions.py +232 -0
  18. ergoda-0.1.0/src/ergoda/analyse/failures.py +283 -0
  19. ergoda-0.1.0/src/ergoda/analyse/groundtruth.py +342 -0
  20. ergoda-0.1.0/src/ergoda/analyse/judge.py +91 -0
  21. ergoda-0.1.0/src/ergoda/analyse/modes.py +448 -0
  22. ergoda-0.1.0/src/ergoda/analyse/modes_cli.py +339 -0
  23. ergoda-0.1.0/src/ergoda/analyse/pricing.py +137 -0
  24. ergoda-0.1.0/src/ergoda/analyse/prose_judge.py +92 -0
  25. ergoda-0.1.0/src/ergoda/analyse/render.py +178 -0
  26. ergoda-0.1.0/src/ergoda/analyse/report.py +807 -0
  27. ergoda-0.1.0/src/ergoda/analyse/reversibility.py +1374 -0
  28. ergoda-0.1.0/src/ergoda/analyse/stability.py +167 -0
  29. ergoda-0.1.0/src/ergoda/analyse/trajectory.py +561 -0
  30. ergoda-0.1.0/src/ergoda/cli.py +904 -0
  31. ergoda-0.1.0/src/ergoda/cluster.py +461 -0
  32. ergoda-0.1.0/src/ergoda/commitments.py +1251 -0
  33. ergoda-0.1.0/src/ergoda/costs.py +217 -0
  34. ergoda-0.1.0/src/ergoda/dataplane/__init__.py +3 -0
  35. ergoda-0.1.0/src/ergoda/dataplane/admission.py +149 -0
  36. ergoda-0.1.0/src/ergoda/dataplane/anomaly.py +48 -0
  37. ergoda-0.1.0/src/ergoda/dataplane/app.py +544 -0
  38. ergoda-0.1.0/src/ergoda/dataplane/audit.py +48 -0
  39. ergoda-0.1.0/src/ergoda/dataplane/cli.py +448 -0
  40. ergoda-0.1.0/src/ergoda/dataplane/context.py +104 -0
  41. ergoda-0.1.0/src/ergoda/dataplane/exporters.py +257 -0
  42. ergoda-0.1.0/src/ergoda/dataplane/governed.py +1112 -0
  43. ergoda-0.1.0/src/ergoda/dataplane/limits.py +301 -0
  44. ergoda-0.1.0/src/ergoda/dataplane/metrics.py +458 -0
  45. ergoda-0.1.0/src/ergoda/dataplane/observe.py +628 -0
  46. ergoda-0.1.0/src/ergoda/dataplane/observe_cli.py +168 -0
  47. ergoda-0.1.0/src/ergoda/dataplane/policy.py +90 -0
  48. ergoda-0.1.0/src/ergoda/dataplane/quota.py +75 -0
  49. ergoda-0.1.0/src/ergoda/dataplane/reload.py +457 -0
  50. ergoda-0.1.0/src/ergoda/dataplane/routing.py +200 -0
  51. ergoda-0.1.0/src/ergoda/dataplane/shapes/__init__.py +208 -0
  52. ergoda-0.1.0/src/ergoda/dataplane/shapes/anthropic.py +302 -0
  53. ergoda-0.1.0/src/ergoda/dataplane/shapes/openai.py +193 -0
  54. ergoda-0.1.0/src/ergoda/dataplane/state.py +266 -0
  55. ergoda-0.1.0/src/ergoda/extras.py +43 -0
  56. ergoda-0.1.0/src/ergoda/golden/__init__.py +22 -0
  57. ergoda-0.1.0/src/ergoda/golden/cli.py +252 -0
  58. ergoda-0.1.0/src/ergoda/golden/model.py +151 -0
  59. ergoda-0.1.0/src/ergoda/golden/runner.py +298 -0
  60. ergoda-0.1.0/src/ergoda/grading.py +316 -0
  61. ergoda-0.1.0/src/ergoda/inference/__init__.py +58 -0
  62. ergoda-0.1.0/src/ergoda/inference/bootstrap.py +479 -0
  63. ergoda-0.1.0/src/ergoda/inference/conformal.py +343 -0
  64. ergoda-0.1.0/src/ergoda/inference/credibility.py +123 -0
  65. ergoda-0.1.0/src/ergoda/inference/intervals.py +44 -0
  66. ergoda-0.1.0/src/ergoda/inference/ope.py +196 -0
  67. ergoda-0.1.0/src/ergoda/inference/power.py +101 -0
  68. ergoda-0.1.0/src/ergoda/inference/ppi.py +264 -0
  69. ergoda-0.1.0/src/ergoda/inference/sequential.py +344 -0
  70. ergoda-0.1.0/src/ergoda/inference/trajectory.py +256 -0
  71. ergoda-0.1.0/src/ergoda/jsonl.py +32 -0
  72. ergoda-0.1.0/src/ergoda/llm.py +791 -0
  73. ergoda-0.1.0/src/ergoda/parallel.py +57 -0
  74. ergoda-0.1.0/src/ergoda/preflight.py +788 -0
  75. ergoda-0.1.0/src/ergoda/providers/__init__.py +107 -0
  76. ergoda-0.1.0/src/ergoda/providers/anthropic.py +342 -0
  77. ergoda-0.1.0/src/ergoda/providers/batch.py +215 -0
  78. ergoda-0.1.0/src/ergoda/providers/evaluation.py +316 -0
  79. ergoda-0.1.0/src/ergoda/providers/gemini.py +325 -0
  80. ergoda-0.1.0/src/ergoda/providers/http.py +165 -0
  81. ergoda-0.1.0/src/ergoda/providers/openai.py +165 -0
  82. ergoda-0.1.0/src/ergoda/providers/params.py +201 -0
  83. ergoda-0.1.0/src/ergoda/providers/pricing.py +284 -0
  84. ergoda-0.1.0/src/ergoda/providers/registry.py +137 -0
  85. ergoda-0.1.0/src/ergoda/providers/shape.py +136 -0
  86. ergoda-0.1.0/src/ergoda/published.py +125 -0
  87. ergoda-0.1.0/src/ergoda/sample/__init__.py +1 -0
  88. ergoda-0.1.0/src/ergoda/sample/traces.jsonl +114 -0
  89. ergoda-0.1.0/src/ergoda/schemas.py +549 -0
  90. ergoda-0.1.0/src/ergoda/sdk/__init__.py +153 -0
  91. ergoda-0.1.0/src/ergoda/sdk/assurance.py +293 -0
  92. ergoda-0.1.0/src/ergoda/sdk/classify.py +115 -0
  93. ergoda-0.1.0/src/ergoda/sdk/cli.py +717 -0
  94. ergoda-0.1.0/src/ergoda/sdk/client.py +5239 -0
  95. ergoda-0.1.0/src/ergoda/sdk/confidence.py +527 -0
  96. ergoda-0.1.0/src/ergoda/sdk/declarations.py +593 -0
  97. ergoda-0.1.0/src/ergoda/sdk/evidence.py +265 -0
  98. ergoda-0.1.0/src/ergoda/sdk/explain.py +174 -0
  99. ergoda-0.1.0/src/ergoda/sdk/gate.py +2925 -0
  100. ergoda-0.1.0/src/ergoda/sdk/phase.py +473 -0
  101. ergoda-0.1.0/src/ergoda/sdk/recording.py +300 -0
  102. ergoda-0.1.0/src/ergoda/sdk/spend.py +627 -0
  103. ergoda-0.1.0/src/ergoda/sdk/status.py +267 -0
  104. ergoda-0.1.0/src/ergoda/sdk/streams.py +347 -0
  105. ergoda-0.1.0/src/ergoda/serving.py +671 -0
  106. ergoda-0.1.0/src/ergoda/traces/__init__.py +0 -0
  107. ergoda-0.1.0/src/ergoda/traces/model.py +177 -0
  108. ergoda-0.1.0/src/ergoda/traces/parse.py +723 -0
  109. ergoda-0.1.0/src/ergoda/transports/__init__.py +96 -0
  110. ergoda-0.1.0/src/ergoda/wire.py +2766 -0
@@ -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
@@ -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