meridian-client 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.
- meridian_client-0.1.0/LICENSE +21 -0
- meridian_client-0.1.0/PKG-INFO +193 -0
- meridian_client-0.1.0/README.md +22 -0
- meridian_client-0.1.0/SKILL.md +181 -0
- meridian_client-0.1.0/meridian/__init__.py +17 -0
- meridian_client-0.1.0/meridian/client.py +405 -0
- meridian_client-0.1.0/meridian/exceptions.py +29 -0
- meridian_client-0.1.0/meridian/models.py +166 -0
- meridian_client-0.1.0/meridian_client.egg-info/PKG-INFO +193 -0
- meridian_client-0.1.0/meridian_client.egg-info/SOURCES.txt +13 -0
- meridian_client-0.1.0/meridian_client.egg-info/dependency_links.txt +1 -0
- meridian_client-0.1.0/meridian_client.egg-info/requires.txt +1 -0
- meridian_client-0.1.0/meridian_client.egg-info/top_level.txt +1 -0
- meridian_client-0.1.0/pyproject.toml +15 -0
- meridian_client-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 knjpollard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meridian-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Meridian Governance Router
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/knjpollard/meridian
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: requests>=2.28.0
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
name: meridian-integration
|
|
15
|
+
description: How to call Meridian (the governance router) correctly from a new, standalone client — auth, the onboarding sequence, and what's still house/console-only.
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Integrating with Meridian
|
|
19
|
+
|
|
20
|
+
Meridian is a policy-governed inference router: you ask it "which model should
|
|
21
|
+
`agent_name` use right now", and it answers using project/agent policy
|
|
22
|
+
(quality, cost, risk, allowed/denied hubs, data classification) rather than
|
|
23
|
+
you hardcoding a model name. It knows nothing about MTA specifically — any
|
|
24
|
+
client that holds a valid key can use it standalone.
|
|
25
|
+
|
|
26
|
+
Full generated route-by-route reference: `docs/api/api-reference.md` in this
|
|
27
|
+
repo (regenerate with `python3 scripts/gen_api_docs.py` after any route
|
|
28
|
+
change — it reads the live FastAPI app, so it can't go stale the way a
|
|
29
|
+
hand-written doc can).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install meridian-client # published — the normal path, or
|
|
35
|
+
pip install -e meridian-client/ # editable, from this repo (contributing to the SDK itself)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Prefer a terminal to Python? `pip install meridian-governance-cli` wraps
|
|
39
|
+
this same SDK as subcommands (`meridian signup`, `meridian resolve
|
|
40
|
+
my_agent`, `meridian keys list`, ...) — installs as the `meridian` command;
|
|
41
|
+
see `meridian-cli/README.md` (not to be confused with the unrelated
|
|
42
|
+
`meridian-cli` package also on PyPI). No chat loop, no personas; it's a
|
|
43
|
+
management CLI over the API, not a clone of `mta-cli`'s consumer-companion
|
|
44
|
+
command set.
|
|
45
|
+
|
|
46
|
+
## Auth — read this before anything else
|
|
47
|
+
|
|
48
|
+
Meridian accepts a key two ways; it doesn't matter which:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Authorization: Bearer mdn_xxxxxxxx
|
|
52
|
+
x-api-key: mdn_xxxxxxxx
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With no key at all: **on-prem only**, and only if that instance was started
|
|
56
|
+
without `MERIDIAN_AUTH_REQUIRED=true`. The request resolves to the instance's
|
|
57
|
+
single default org. Every cloud deployment (`dev-meridian.mytrustworthy.ai`
|
|
58
|
+
and any other `MERIDIAN_DEPLOYMENT=cloud` instance) requires a key — no key
|
|
59
|
+
gets a flat 401, not a default org.
|
|
60
|
+
|
|
61
|
+
## Getting your first key
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from meridian import Meridian
|
|
65
|
+
|
|
66
|
+
m = Meridian.signup("https://dev-meridian.mytrustworthy.ai",
|
|
67
|
+
org_name="my-project", email="me@example.com", password="...")
|
|
68
|
+
# m is now an authenticated client. m.org_id and m.signup_response are set.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
One call: creates a new org (always `free` tier — self-serve can't grant
|
|
72
|
+
itself more than that), its first user, and its first key, and returns a
|
|
73
|
+
`Meridian` already authenticated with it. This is the *only* unauthenticated
|
|
74
|
+
write on the whole API — everywhere else, minting a key or registering a
|
|
75
|
+
user needs an `org_id` that a cold caller doesn't have yet, which is exactly
|
|
76
|
+
the chicken-and-egg problem this one call exists to break.
|
|
77
|
+
|
|
78
|
+
If you already have a key (an account team provisioned you directly, or
|
|
79
|
+
you're adding a second client to an org that already signed up):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
m = Meridian("https://dev-meridian.mytrustworthy.ai", api_key="mdn_...")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Three tiers, one engine
|
|
86
|
+
|
|
87
|
+
There's one governance engine underneath all of this — the tiers below are
|
|
88
|
+
just how much of it you choose to use, not three different products:
|
|
89
|
+
|
|
90
|
+
- **Individual / fast start:** pin a model and go. `pinned_model` skips
|
|
91
|
+
scoring entirely (`resolver.py`'s "Step 3: Pinned model — skip scoring") —
|
|
92
|
+
deterministic, zero policy to configure.
|
|
93
|
+
- **Business / small team:** the quality/cost/risk sliders. Same `create_agent`
|
|
94
|
+
call, just set overrides instead of (or in addition to) a pin; invite
|
|
95
|
+
teammates into the same org via `POST /api/v1/auth/register` once you're
|
|
96
|
+
authenticated.
|
|
97
|
+
- **Enterprise / custom governance:** change-requests and approval
|
|
98
|
+
(`create_change_request` → `approve_change_request`), hub allow/denylists,
|
|
99
|
+
data classification enforcement (`pii` hard-forces risk to 1, on-prem only).
|
|
100
|
+
All already built; nothing here is Enterprise-only code, just the parts a
|
|
101
|
+
fast-start integration doesn't need to touch.
|
|
102
|
+
|
|
103
|
+
## The onboarding sequence
|
|
104
|
+
|
|
105
|
+
A tenant key can do all of this itself — no console needed past signup:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# 1. A project: your governance boundary. One org can have many.
|
|
109
|
+
project = m.create_project(
|
|
110
|
+
org_id=m.org_id,
|
|
111
|
+
name="my-standalone-agent",
|
|
112
|
+
quality=7, cost=5, risk=5, # 0-10 sliders, project-level defaults
|
|
113
|
+
classification="general", # "pii" forces risk=1 regardless of the slider
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# 2. An agent inside it. This is the identity you resolve against — not a
|
|
117
|
+
# user, not an API key, an AGENT. One project can have many, each with its
|
|
118
|
+
# own overrides.
|
|
119
|
+
agent = m.create_agent(
|
|
120
|
+
project.id, "my_agent",
|
|
121
|
+
quality_override=9, # omit to inherit the project default
|
|
122
|
+
allowed_hubs=["anthropic-direct", "bedrock"], # omit for no hub restriction
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# 3. Resolve. Call this every time you're about to make an inference call —
|
|
126
|
+
# it's cheap, and policy can change between calls.
|
|
127
|
+
decision = m.resolve("my_agent", project_id=project.id)
|
|
128
|
+
print(decision.model, decision.hub) # e.g. "claude-opus-5" "anthropic-direct"
|
|
129
|
+
|
|
130
|
+
# 4. After the real inference call, report usage so cost tracking means
|
|
131
|
+
# something.
|
|
132
|
+
m.audit(decision.routing_log_id, input_tokens=512, output_tokens=128,
|
|
133
|
+
latency_ms=840)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
That's the whole golden path. `m.explain(decision.routing_log_id)` gets you
|
|
137
|
+
the full candidate breakdown (every model considered, why each one was
|
|
138
|
+
eliminated or scored where it scored) if you need to show your work.
|
|
139
|
+
|
|
140
|
+
## Without the SDK (raw HTTP)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
curl -X POST https://dev-meridian.mytrustworthy.ai/api/v1/resolve \
|
|
144
|
+
-H "Authorization: Bearer mdn_..." -H "Content-Type: application/json" \
|
|
145
|
+
-d '{"agent_name": "my_agent", "project_id": 123}'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Every route in `docs/api/api-reference.md` works the same way — method, path,
|
|
149
|
+
JSON body of the fields listed, same two auth headers.
|
|
150
|
+
|
|
151
|
+
## What a tenant key cannot do (yet)
|
|
152
|
+
|
|
153
|
+
`docs/api/api-reference.md` marks every route **tenant key: yes/no**. The
|
|
154
|
+
"no" routes answer `403 "This route is not yet tenant-scoped; refused for
|
|
155
|
+
tenant keys"` regardless of whether your key is valid — it's a blanket
|
|
156
|
+
refusal by path, not a permission check. As of this writing that's mainly:
|
|
157
|
+
hub/model/deployment registration and edits, org creation and edits, and most
|
|
158
|
+
cost/governance console views. Those need a house key or the console. Project
|
|
159
|
+
and agent create/update/delete, resolve, audit, explain, simulate, and
|
|
160
|
+
change-requests are all tenant-reachable.
|
|
161
|
+
|
|
162
|
+
## Gotchas
|
|
163
|
+
|
|
164
|
+
- **`PUT /api/v1/projects/{id}/lifecycle` is a stub.** It validates the
|
|
165
|
+
request and echoes the current row back — it does not change anything.
|
|
166
|
+
Don't build on it (`meridian#7`'s predecessor finding).
|
|
167
|
+
- **The SDK's response objects have an `.extra` dict.** Every typed field the
|
|
168
|
+
SDK knows about is a real attribute (`agent.allowed_hubs`); anything the
|
|
169
|
+
server returns that this SDK version doesn't model yet lands in
|
|
170
|
+
`agent.extra` instead of silently vanishing. If a field you expect is
|
|
171
|
+
`None`, check `.extra` before assuming the server didn't send it.
|
|
172
|
+
- **`resolve_failover` only writes an audit record if you pass
|
|
173
|
+
`agent_name`.** Without it you get a plain hub-allowlist fallback with no
|
|
174
|
+
governance and no audit trail — fine for "just get me anything that
|
|
175
|
+
works", wrong for anything that needs to explain itself later.
|
|
176
|
+
- **Tool-access governance does not exist yet.** Meridian governs *which
|
|
177
|
+
model* an agent may use. It does not yet govern *which tools* an agent may
|
|
178
|
+
call — there's no route for it, and the schema slots for it
|
|
179
|
+
(`agent.agent_tools`, `agent.skill_ids`) are unpopulated. If your new agent
|
|
180
|
+
needs Meridian to gate tool access rather than just model choice, that's a
|
|
181
|
+
real gap, not a documentation gap — see
|
|
182
|
+
`obsidian-vault-kb/relays/meridian/2026-10-12-should-meridian-govern-tools-and-what-that-costs-in-apis.md`
|
|
183
|
+
for the live design discussion.
|
|
184
|
+
- **Forgot a built-in-auth password? `POST /auth/forgot-password` /
|
|
185
|
+
`POST /auth/reset-password`** (meridian#10), also reachable from the
|
|
186
|
+
console's "Forgot password?" link. Requires `RESEND_API_KEY` to actually
|
|
187
|
+
be configured on the instance — without it the request fails loudly
|
|
188
|
+
rather than handing the token back in the response. That's deliberate:
|
|
189
|
+
an earlier version of this capability did exactly that and was removed on
|
|
190
|
+
2026-09-12 because it let anyone who knew an email address take over that
|
|
191
|
+
account. There is no magic-link *login* — only password reset; you still
|
|
192
|
+
need to know a password to sign in, the email only proves you own the
|
|
193
|
+
inbox.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# meridian-client
|
|
2
|
+
|
|
3
|
+
Python SDK for [Meridian Governance Router](https://dev-meridian.mytrustworthy.ai/) — one class over the REST API, no database access.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from meridian import Meridian
|
|
7
|
+
|
|
8
|
+
m = Meridian.signup("https://dev-meridian.mytrustworthy.ai",
|
|
9
|
+
org_name="acme", email="me@acme.com", password="...")
|
|
10
|
+
|
|
11
|
+
project = m.create_project(org_id=m.org_id, name="support", quality=8, cost=4, risk=5)
|
|
12
|
+
agent = m.create_agent(project.id, "ticket_summariser", cost_override=8)
|
|
13
|
+
decision = m.resolve("ticket_summariser")
|
|
14
|
+
|
|
15
|
+
print(decision.model, decision.hub, decision.routing_log_id)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Already have a key? `Meridian(base_url, api_key="mdn_...")` — skip signup.
|
|
19
|
+
|
|
20
|
+
Full quickstart, tiers, and gotchas: [dev-meridian.mytrustworthy.ai/docs](https://dev-meridian.mytrustworthy.ai/docs/index.html#quickstart). Full generated API reference, every route: [dev-meridian.mytrustworthy.ai/docs/#api-reference](https://dev-meridian.mytrustworthy.ai/docs/index.html#api-reference).
|
|
21
|
+
|
|
22
|
+
Prefer a terminal to Python? See [`meridian-governance-cli`](https://pypi.org/project/meridian-governance-cli/) — a command-line client over this same SDK.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: meridian-integration
|
|
3
|
+
description: How to call Meridian (the governance router) correctly from a new, standalone client — auth, the onboarding sequence, and what's still house/console-only.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Integrating with Meridian
|
|
7
|
+
|
|
8
|
+
Meridian is a policy-governed inference router: you ask it "which model should
|
|
9
|
+
`agent_name` use right now", and it answers using project/agent policy
|
|
10
|
+
(quality, cost, risk, allowed/denied hubs, data classification) rather than
|
|
11
|
+
you hardcoding a model name. It knows nothing about MTA specifically — any
|
|
12
|
+
client that holds a valid key can use it standalone.
|
|
13
|
+
|
|
14
|
+
Full generated route-by-route reference: `docs/api/api-reference.md` in this
|
|
15
|
+
repo (regenerate with `python3 scripts/gen_api_docs.py` after any route
|
|
16
|
+
change — it reads the live FastAPI app, so it can't go stale the way a
|
|
17
|
+
hand-written doc can).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install meridian-client # published — the normal path, or
|
|
23
|
+
pip install -e meridian-client/ # editable, from this repo (contributing to the SDK itself)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Prefer a terminal to Python? `pip install meridian-governance-cli` wraps
|
|
27
|
+
this same SDK as subcommands (`meridian signup`, `meridian resolve
|
|
28
|
+
my_agent`, `meridian keys list`, ...) — installs as the `meridian` command;
|
|
29
|
+
see `meridian-cli/README.md` (not to be confused with the unrelated
|
|
30
|
+
`meridian-cli` package also on PyPI). No chat loop, no personas; it's a
|
|
31
|
+
management CLI over the API, not a clone of `mta-cli`'s consumer-companion
|
|
32
|
+
command set.
|
|
33
|
+
|
|
34
|
+
## Auth — read this before anything else
|
|
35
|
+
|
|
36
|
+
Meridian accepts a key two ways; it doesn't matter which:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Authorization: Bearer mdn_xxxxxxxx
|
|
40
|
+
x-api-key: mdn_xxxxxxxx
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
With no key at all: **on-prem only**, and only if that instance was started
|
|
44
|
+
without `MERIDIAN_AUTH_REQUIRED=true`. The request resolves to the instance's
|
|
45
|
+
single default org. Every cloud deployment (`dev-meridian.mytrustworthy.ai`
|
|
46
|
+
and any other `MERIDIAN_DEPLOYMENT=cloud` instance) requires a key — no key
|
|
47
|
+
gets a flat 401, not a default org.
|
|
48
|
+
|
|
49
|
+
## Getting your first key
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from meridian import Meridian
|
|
53
|
+
|
|
54
|
+
m = Meridian.signup("https://dev-meridian.mytrustworthy.ai",
|
|
55
|
+
org_name="my-project", email="me@example.com", password="...")
|
|
56
|
+
# m is now an authenticated client. m.org_id and m.signup_response are set.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
One call: creates a new org (always `free` tier — self-serve can't grant
|
|
60
|
+
itself more than that), its first user, and its first key, and returns a
|
|
61
|
+
`Meridian` already authenticated with it. This is the *only* unauthenticated
|
|
62
|
+
write on the whole API — everywhere else, minting a key or registering a
|
|
63
|
+
user needs an `org_id` that a cold caller doesn't have yet, which is exactly
|
|
64
|
+
the chicken-and-egg problem this one call exists to break.
|
|
65
|
+
|
|
66
|
+
If you already have a key (an account team provisioned you directly, or
|
|
67
|
+
you're adding a second client to an org that already signed up):
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
m = Meridian("https://dev-meridian.mytrustworthy.ai", api_key="mdn_...")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Three tiers, one engine
|
|
74
|
+
|
|
75
|
+
There's one governance engine underneath all of this — the tiers below are
|
|
76
|
+
just how much of it you choose to use, not three different products:
|
|
77
|
+
|
|
78
|
+
- **Individual / fast start:** pin a model and go. `pinned_model` skips
|
|
79
|
+
scoring entirely (`resolver.py`'s "Step 3: Pinned model — skip scoring") —
|
|
80
|
+
deterministic, zero policy to configure.
|
|
81
|
+
- **Business / small team:** the quality/cost/risk sliders. Same `create_agent`
|
|
82
|
+
call, just set overrides instead of (or in addition to) a pin; invite
|
|
83
|
+
teammates into the same org via `POST /api/v1/auth/register` once you're
|
|
84
|
+
authenticated.
|
|
85
|
+
- **Enterprise / custom governance:** change-requests and approval
|
|
86
|
+
(`create_change_request` → `approve_change_request`), hub allow/denylists,
|
|
87
|
+
data classification enforcement (`pii` hard-forces risk to 1, on-prem only).
|
|
88
|
+
All already built; nothing here is Enterprise-only code, just the parts a
|
|
89
|
+
fast-start integration doesn't need to touch.
|
|
90
|
+
|
|
91
|
+
## The onboarding sequence
|
|
92
|
+
|
|
93
|
+
A tenant key can do all of this itself — no console needed past signup:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# 1. A project: your governance boundary. One org can have many.
|
|
97
|
+
project = m.create_project(
|
|
98
|
+
org_id=m.org_id,
|
|
99
|
+
name="my-standalone-agent",
|
|
100
|
+
quality=7, cost=5, risk=5, # 0-10 sliders, project-level defaults
|
|
101
|
+
classification="general", # "pii" forces risk=1 regardless of the slider
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# 2. An agent inside it. This is the identity you resolve against — not a
|
|
105
|
+
# user, not an API key, an AGENT. One project can have many, each with its
|
|
106
|
+
# own overrides.
|
|
107
|
+
agent = m.create_agent(
|
|
108
|
+
project.id, "my_agent",
|
|
109
|
+
quality_override=9, # omit to inherit the project default
|
|
110
|
+
allowed_hubs=["anthropic-direct", "bedrock"], # omit for no hub restriction
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# 3. Resolve. Call this every time you're about to make an inference call —
|
|
114
|
+
# it's cheap, and policy can change between calls.
|
|
115
|
+
decision = m.resolve("my_agent", project_id=project.id)
|
|
116
|
+
print(decision.model, decision.hub) # e.g. "claude-opus-5" "anthropic-direct"
|
|
117
|
+
|
|
118
|
+
# 4. After the real inference call, report usage so cost tracking means
|
|
119
|
+
# something.
|
|
120
|
+
m.audit(decision.routing_log_id, input_tokens=512, output_tokens=128,
|
|
121
|
+
latency_ms=840)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
That's the whole golden path. `m.explain(decision.routing_log_id)` gets you
|
|
125
|
+
the full candidate breakdown (every model considered, why each one was
|
|
126
|
+
eliminated or scored where it scored) if you need to show your work.
|
|
127
|
+
|
|
128
|
+
## Without the SDK (raw HTTP)
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
curl -X POST https://dev-meridian.mytrustworthy.ai/api/v1/resolve \
|
|
132
|
+
-H "Authorization: Bearer mdn_..." -H "Content-Type: application/json" \
|
|
133
|
+
-d '{"agent_name": "my_agent", "project_id": 123}'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Every route in `docs/api/api-reference.md` works the same way — method, path,
|
|
137
|
+
JSON body of the fields listed, same two auth headers.
|
|
138
|
+
|
|
139
|
+
## What a tenant key cannot do (yet)
|
|
140
|
+
|
|
141
|
+
`docs/api/api-reference.md` marks every route **tenant key: yes/no**. The
|
|
142
|
+
"no" routes answer `403 "This route is not yet tenant-scoped; refused for
|
|
143
|
+
tenant keys"` regardless of whether your key is valid — it's a blanket
|
|
144
|
+
refusal by path, not a permission check. As of this writing that's mainly:
|
|
145
|
+
hub/model/deployment registration and edits, org creation and edits, and most
|
|
146
|
+
cost/governance console views. Those need a house key or the console. Project
|
|
147
|
+
and agent create/update/delete, resolve, audit, explain, simulate, and
|
|
148
|
+
change-requests are all tenant-reachable.
|
|
149
|
+
|
|
150
|
+
## Gotchas
|
|
151
|
+
|
|
152
|
+
- **`PUT /api/v1/projects/{id}/lifecycle` is a stub.** It validates the
|
|
153
|
+
request and echoes the current row back — it does not change anything.
|
|
154
|
+
Don't build on it (`meridian#7`'s predecessor finding).
|
|
155
|
+
- **The SDK's response objects have an `.extra` dict.** Every typed field the
|
|
156
|
+
SDK knows about is a real attribute (`agent.allowed_hubs`); anything the
|
|
157
|
+
server returns that this SDK version doesn't model yet lands in
|
|
158
|
+
`agent.extra` instead of silently vanishing. If a field you expect is
|
|
159
|
+
`None`, check `.extra` before assuming the server didn't send it.
|
|
160
|
+
- **`resolve_failover` only writes an audit record if you pass
|
|
161
|
+
`agent_name`.** Without it you get a plain hub-allowlist fallback with no
|
|
162
|
+
governance and no audit trail — fine for "just get me anything that
|
|
163
|
+
works", wrong for anything that needs to explain itself later.
|
|
164
|
+
- **Tool-access governance does not exist yet.** Meridian governs *which
|
|
165
|
+
model* an agent may use. It does not yet govern *which tools* an agent may
|
|
166
|
+
call — there's no route for it, and the schema slots for it
|
|
167
|
+
(`agent.agent_tools`, `agent.skill_ids`) are unpopulated. If your new agent
|
|
168
|
+
needs Meridian to gate tool access rather than just model choice, that's a
|
|
169
|
+
real gap, not a documentation gap — see
|
|
170
|
+
`obsidian-vault-kb/relays/meridian/2026-10-12-should-meridian-govern-tools-and-what-that-costs-in-apis.md`
|
|
171
|
+
for the live design discussion.
|
|
172
|
+
- **Forgot a built-in-auth password? `POST /auth/forgot-password` /
|
|
173
|
+
`POST /auth/reset-password`** (meridian#10), also reachable from the
|
|
174
|
+
console's "Forgot password?" link. Requires `RESEND_API_KEY` to actually
|
|
175
|
+
be configured on the instance — without it the request fails loudly
|
|
176
|
+
rather than handing the token back in the response. That's deliberate:
|
|
177
|
+
an earlier version of this capability did exactly that and was removed on
|
|
178
|
+
2026-09-12 because it let anyone who knew an email address take over that
|
|
179
|
+
account. There is no magic-link *login* — only password reset; you still
|
|
180
|
+
need to know a password to sign in, the email only proves you own the
|
|
181
|
+
inbox.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Meridian Governance Router — Python SDK.
|
|
2
|
+
|
|
3
|
+
from meridian import Meridian
|
|
4
|
+
m = Meridian("http://meridian:8080")
|
|
5
|
+
decision = m.resolve("my_agent")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import Meridian
|
|
9
|
+
from .models import Org, Project, Agent, Hub, Model, Deployment, Principal, Decision, ChangeRequest
|
|
10
|
+
from .exceptions import MeridianError, NotFoundError, ConflictError, ValidationError, ServerError
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Meridian",
|
|
15
|
+
"Org", "Project", "Agent", "Hub", "Model", "Deployment", "Principal", "Decision", "ChangeRequest",
|
|
16
|
+
"MeridianError", "NotFoundError", "ConflictError", "ValidationError", "ServerError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Meridian SDK — one class, all methods.
|
|
2
|
+
|
|
3
|
+
from meridian import Meridian
|
|
4
|
+
m = Meridian("http://meridian:8080")
|
|
5
|
+
decision = m.resolve("my_agent")
|
|
6
|
+
|
|
7
|
+
Against a deployment with auth enabled (every cloud instance; any on-prem
|
|
8
|
+
instance that has API keys configured), pass `api_key`:
|
|
9
|
+
|
|
10
|
+
from meridian import Meridian
|
|
11
|
+
m = Meridian("https://dev-meridian.mytrustworthy.ai", api_key="mdn_...")
|
|
12
|
+
|
|
13
|
+
Without `api_key`, no Authorization/x-api-key header is sent at all. That is
|
|
14
|
+
only valid against an on-prem instance running with MERIDIAN_AUTH_REQUIRED
|
|
15
|
+
unset — it resolves to that instance's default org. Every other deployment
|
|
16
|
+
answers 401.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Optional, List, Dict, Any
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from .models import Org, Project, Agent, Hub, Model, Deployment, Principal, Decision, ChangeRequest, ApiKey
|
|
26
|
+
from .exceptions import MeridianError, NotFoundError, ConflictError, ValidationError, ServerError
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("meridian-client")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Meridian:
|
|
32
|
+
"""Meridian Governance Router client.
|
|
33
|
+
|
|
34
|
+
One class, all methods. HTTP only — no DB, no SQL.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, base_url: str = "http://localhost:8080",
|
|
38
|
+
api_key: Optional[str] = None, auth_scheme: str = "bearer",
|
|
39
|
+
principal: Optional[str] = None,
|
|
40
|
+
timeout: int = 30, retries: int = 3):
|
|
41
|
+
"""
|
|
42
|
+
api_key: a key minted by POST /api/v1/keys (format `mdn_...`). Scopes
|
|
43
|
+
every call to that key's org and is required by any deployment
|
|
44
|
+
running with auth enforced (cloud, always).
|
|
45
|
+
auth_scheme: "bearer" sends `Authorization: Bearer <key>`; "x-api-key"
|
|
46
|
+
sends `x-api-key: <key>`. Meridian accepts both — pick whichever a
|
|
47
|
+
surrounding gateway or proxy already standardises on.
|
|
48
|
+
principal: an external identity to attribute requests to (sent as
|
|
49
|
+
`X-Meridian-Principal`). Optional; falls back to the key alone.
|
|
50
|
+
"""
|
|
51
|
+
self.base_url = base_url.rstrip("/")
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self.auth_scheme = auth_scheme
|
|
54
|
+
self.principal = principal
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.retries = retries
|
|
57
|
+
self._session = requests.Session()
|
|
58
|
+
|
|
59
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
60
|
+
headers = {}
|
|
61
|
+
if self.api_key:
|
|
62
|
+
if self.auth_scheme == "x-api-key":
|
|
63
|
+
headers["x-api-key"] = self.api_key
|
|
64
|
+
else:
|
|
65
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
66
|
+
if self.principal:
|
|
67
|
+
headers["X-Meridian-Principal"] = self.principal
|
|
68
|
+
return headers
|
|
69
|
+
|
|
70
|
+
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
71
|
+
"""Make an HTTP request with retry and error handling."""
|
|
72
|
+
url = f"{self.base_url}{path}"
|
|
73
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
74
|
+
headers = self._auth_headers()
|
|
75
|
+
headers.update(kwargs.pop("headers", None) or {})
|
|
76
|
+
if headers:
|
|
77
|
+
kwargs["headers"] = headers
|
|
78
|
+
|
|
79
|
+
last_err = None
|
|
80
|
+
for attempt in range(self.retries):
|
|
81
|
+
try:
|
|
82
|
+
resp = self._session.request(method, url, **kwargs)
|
|
83
|
+
|
|
84
|
+
if resp.status_code == 200 or resp.status_code == 201:
|
|
85
|
+
return resp.json()
|
|
86
|
+
elif resp.status_code == 404:
|
|
87
|
+
raise NotFoundError(f"Not found: {path}", 404, resp.json() if resp.text else None)
|
|
88
|
+
elif resp.status_code == 409:
|
|
89
|
+
raise ConflictError(f"Already exists: {path}", 409, resp.json() if resp.text else None)
|
|
90
|
+
elif resp.status_code == 422:
|
|
91
|
+
detail = resp.json() if resp.text else {}
|
|
92
|
+
raise ValidationError(
|
|
93
|
+
detail.get("message") or detail.get("detail", {}).get("error", "Validation failed"),
|
|
94
|
+
422, detail)
|
|
95
|
+
elif resp.status_code >= 500:
|
|
96
|
+
last_err = ServerError(f"Server error {resp.status_code}", resp.status_code)
|
|
97
|
+
time.sleep(2 ** attempt)
|
|
98
|
+
continue
|
|
99
|
+
else:
|
|
100
|
+
raise MeridianError(f"HTTP {resp.status_code}: {resp.text[:200]}", resp.status_code)
|
|
101
|
+
|
|
102
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
|
103
|
+
last_err = MeridianError(f"Connection failed: {e}")
|
|
104
|
+
if attempt < self.retries - 1:
|
|
105
|
+
time.sleep(2 ** attempt)
|
|
106
|
+
continue
|
|
107
|
+
raise last_err
|
|
108
|
+
|
|
109
|
+
if last_err:
|
|
110
|
+
raise last_err
|
|
111
|
+
|
|
112
|
+
def _get(self, path: str, **params) -> Any:
|
|
113
|
+
return self._request("GET", path, params=params)
|
|
114
|
+
|
|
115
|
+
def _post(self, path: str, data: dict) -> dict:
|
|
116
|
+
return self._request("POST", path, json=data)
|
|
117
|
+
|
|
118
|
+
def _put(self, path: str, data: dict) -> dict:
|
|
119
|
+
return self._request("PUT", path, json=data)
|
|
120
|
+
|
|
121
|
+
def _delete(self, path: str) -> dict:
|
|
122
|
+
return self._request("DELETE", path)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _wrap(cls, data: dict):
|
|
126
|
+
"""Build a typed response, routing fields the SDK doesn't know yet
|
|
127
|
+
into `.extra` instead of dropping them."""
|
|
128
|
+
known = cls.__dataclass_fields__
|
|
129
|
+
matched = {k: v for k, v in data.items() if k in known and k != "extra"}
|
|
130
|
+
matched["extra"] = {k: v for k, v in data.items() if k not in known}
|
|
131
|
+
return cls(**matched)
|
|
132
|
+
|
|
133
|
+
# ── Health ──
|
|
134
|
+
|
|
135
|
+
def health(self) -> dict:
|
|
136
|
+
return self._get("/health")
|
|
137
|
+
|
|
138
|
+
# ── Signup ──
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def signup(cls, base_url: str, org_name: str, email: str, password: str,
|
|
142
|
+
name: str = None, **client_kwargs) -> "Meridian":
|
|
143
|
+
"""Create a brand-new org, its first user, and its first key, then
|
|
144
|
+
return a Meridian client already authenticated as that key.
|
|
145
|
+
|
|
146
|
+
The only call on the whole API that needs no existing org or key —
|
|
147
|
+
everywhere else, bootstrapping a tenant is a chicken-and-egg problem
|
|
148
|
+
on a multi-tenant deployment. Always creates a 'free'-tier org; ask
|
|
149
|
+
your account team for anything above that.
|
|
150
|
+
"""
|
|
151
|
+
tmp = cls(base_url)
|
|
152
|
+
data = tmp._post("/api/v1/auth/signup", {
|
|
153
|
+
"org_name": org_name, "email": email, "password": password, "name": name,
|
|
154
|
+
})
|
|
155
|
+
client = cls(base_url, api_key=data["api_key"], **client_kwargs)
|
|
156
|
+
client.org_id = data["org_id"]
|
|
157
|
+
client.signup_response = data
|
|
158
|
+
return client
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def login(cls, base_url: str, email: str, password: str,
|
|
162
|
+
key_name: str = None, **client_kwargs) -> "Meridian":
|
|
163
|
+
"""Authenticate with email + password, then mint a new API key and
|
|
164
|
+
return a client holding it.
|
|
165
|
+
|
|
166
|
+
/auth/login itself returns a short-lived JWT (session token for a
|
|
167
|
+
browser), not an api key — fine for a console tab open for an hour,
|
|
168
|
+
wrong for a CLI or script meant to keep working for months without
|
|
169
|
+
asking you to log in again. So this does both calls: login to prove
|
|
170
|
+
who you are, then POST /keys (authenticated by the JWT it just got,
|
|
171
|
+
since Meridian accepts either credential shape) to get a durable
|
|
172
|
+
key, and discards the JWT. Login again anytime to mint another key;
|
|
173
|
+
existing keys are unaffected.
|
|
174
|
+
"""
|
|
175
|
+
tmp = cls(base_url)
|
|
176
|
+
login_data = tmp._post("/api/v1/auth/login", {"email": email, "password": password})
|
|
177
|
+
jwt_client = cls(base_url, api_key=login_data["token"], **client_kwargs)
|
|
178
|
+
key_data = jwt_client._post("/api/v1/keys", {
|
|
179
|
+
"org_id": login_data["org_id"], "name": key_name or "meridian-client login",
|
|
180
|
+
})
|
|
181
|
+
client = cls(base_url, api_key=key_data["api_key"], **client_kwargs)
|
|
182
|
+
client.org_id = login_data["org_id"]
|
|
183
|
+
client.login_response = login_data
|
|
184
|
+
return client
|
|
185
|
+
|
|
186
|
+
# ── Keys ──
|
|
187
|
+
|
|
188
|
+
def create_key(self, org_id: int, name: str = None) -> dict:
|
|
189
|
+
"""Returns the raw key ONCE — {"api_key": ..., "key_prefix": ..., ...}.
|
|
190
|
+
Not wrapped as ApiKey: the raw key itself isn't a stored field, and
|
|
191
|
+
.list_keys()'s rows never carry it back."""
|
|
192
|
+
return self._post("/api/v1/keys", {"org_id": org_id, "name": name})
|
|
193
|
+
|
|
194
|
+
def list_keys(self) -> List[ApiKey]:
|
|
195
|
+
"""Keys for the authenticated org — prefix only, never the full key."""
|
|
196
|
+
return [self._wrap(ApiKey, k) for k in self._get("/api/v1/keys")]
|
|
197
|
+
|
|
198
|
+
def revoke_key(self, key_id: int) -> dict:
|
|
199
|
+
return self._delete(f"/api/v1/keys/{key_id}")
|
|
200
|
+
|
|
201
|
+
# ── Organizations ──
|
|
202
|
+
|
|
203
|
+
def create_org(self, name: str, quality: int = 5, cost: int = 5, risk: int = 5,
|
|
204
|
+
tier: str = "standard", budget: float = None) -> Org:
|
|
205
|
+
data = self._post("/api/v1/orgs", {
|
|
206
|
+
"name": name, "default_quality": quality, "default_cost": cost,
|
|
207
|
+
"default_risk": risk, "subscription_tier": tier, "monthly_budget_usd": budget,
|
|
208
|
+
})
|
|
209
|
+
return self._wrap(Org, data)
|
|
210
|
+
|
|
211
|
+
def update_org(self, org_id: int, **kwargs) -> Org:
|
|
212
|
+
data = self._put(f"/api/v1/orgs/{org_id}", kwargs)
|
|
213
|
+
return self._wrap(Org, data)
|
|
214
|
+
|
|
215
|
+
# ── Projects ──
|
|
216
|
+
|
|
217
|
+
def create_project(self, org_id: int, name: str, quality: int = None, cost: int = None,
|
|
218
|
+
risk: int = None, classification: str = "general", **kwargs) -> Project:
|
|
219
|
+
payload = {"org_id": org_id, "name": name, "data_classification": classification, **kwargs}
|
|
220
|
+
if quality is not None: payload["quality_default"] = quality
|
|
221
|
+
if cost is not None: payload["cost_default"] = cost
|
|
222
|
+
if risk is not None: payload["risk_default"] = risk
|
|
223
|
+
data = self._post("/api/v1/projects", payload)
|
|
224
|
+
return self._wrap(Project, data)
|
|
225
|
+
|
|
226
|
+
def update_project(self, project_id: int, **kwargs) -> Project:
|
|
227
|
+
data = self._put(f"/api/v1/projects/{project_id}", kwargs)
|
|
228
|
+
return self._wrap(Project, data)
|
|
229
|
+
|
|
230
|
+
def projects(self) -> List[dict]:
|
|
231
|
+
return self._get("/api/v1/projects")
|
|
232
|
+
|
|
233
|
+
# ── Agents ──
|
|
234
|
+
|
|
235
|
+
def create_agent(self, project_id: int, agent_name: str, quality_override: int = None,
|
|
236
|
+
cost_override: int = None, risk_override: int = None,
|
|
237
|
+
pinned_model: str = None, **kwargs) -> Agent:
|
|
238
|
+
payload = {"agent_name": agent_name, **kwargs}
|
|
239
|
+
if quality_override is not None: payload["quality_override"] = quality_override
|
|
240
|
+
if cost_override is not None: payload["cost_override"] = cost_override
|
|
241
|
+
if risk_override is not None: payload["risk_override"] = risk_override
|
|
242
|
+
if pinned_model: payload["pinned_model"] = pinned_model
|
|
243
|
+
data = self._post(f"/api/v1/projects/{project_id}/agents", payload)
|
|
244
|
+
return self._wrap(Agent, data)
|
|
245
|
+
|
|
246
|
+
def update_agent(self, project_id: int, agent_name: str, **kwargs) -> Agent:
|
|
247
|
+
data = self._put(f"/api/v1/projects/{project_id}/agents/{agent_name}", kwargs)
|
|
248
|
+
return self._wrap(Agent, data)
|
|
249
|
+
|
|
250
|
+
def delete_agent(self, project_id: int, agent_name: str) -> dict:
|
|
251
|
+
return self._delete(f"/api/v1/projects/{project_id}/agents/{agent_name}")
|
|
252
|
+
|
|
253
|
+
def agents(self, project_id: int) -> List[dict]:
|
|
254
|
+
return self._get(f"/api/v1/projects/{project_id}/agents")
|
|
255
|
+
|
|
256
|
+
# ── Hubs ──
|
|
257
|
+
|
|
258
|
+
def create_hub(self, name: str, hub_type: str, base_url: str = None,
|
|
259
|
+
risk_score: int = 5, **kwargs) -> Hub:
|
|
260
|
+
data = self._post("/api/v1/hubs", {
|
|
261
|
+
"name": name, "hub_type": hub_type, "base_url": base_url,
|
|
262
|
+
"risk_score": risk_score, **kwargs,
|
|
263
|
+
})
|
|
264
|
+
return self._wrap(Hub, data)
|
|
265
|
+
|
|
266
|
+
def update_hub(self, hub_id: int, **kwargs) -> Hub:
|
|
267
|
+
data = self._put(f"/api/v1/hubs/{hub_id}", kwargs)
|
|
268
|
+
return self._wrap(Hub, data)
|
|
269
|
+
|
|
270
|
+
def delete_hub(self, hub_id: int) -> dict:
|
|
271
|
+
return self._delete(f"/api/v1/hubs/{hub_id}")
|
|
272
|
+
|
|
273
|
+
def hubs(self) -> List[dict]:
|
|
274
|
+
return self._get("/api/v1/hubs")
|
|
275
|
+
|
|
276
|
+
# ── Models ──
|
|
277
|
+
|
|
278
|
+
def create_model(self, name: str, provider: str, quality_tier: float = 5,
|
|
279
|
+
supports_tools: bool = False, **kwargs) -> Model:
|
|
280
|
+
data = self._post("/api/v1/models", {
|
|
281
|
+
"name": name, "provider": provider, "quality_tier": quality_tier,
|
|
282
|
+
"supports_tools": supports_tools, **kwargs,
|
|
283
|
+
})
|
|
284
|
+
return self._wrap(Model, data)
|
|
285
|
+
|
|
286
|
+
def models(self) -> List[dict]:
|
|
287
|
+
return self._get("/api/v1/models")
|
|
288
|
+
|
|
289
|
+
# ── Deployments ──
|
|
290
|
+
|
|
291
|
+
def create_deployment(self, hub_id: int, model_id: int, deployment_key: str,
|
|
292
|
+
backend_model_id: str, cost_input: float = 0,
|
|
293
|
+
cost_output: float = 0, **kwargs) -> Deployment:
|
|
294
|
+
data = self._post(f"/api/v1/hubs/{hub_id}/deployments", {
|
|
295
|
+
"model_id": model_id, "deployment_key": deployment_key,
|
|
296
|
+
"backend_model_id": backend_model_id,
|
|
297
|
+
"cost_per_1m_input": cost_input, "cost_per_1m_output": cost_output, **kwargs,
|
|
298
|
+
})
|
|
299
|
+
return self._wrap(Deployment, data)
|
|
300
|
+
|
|
301
|
+
def update_deployment(self, deployment_id: int, **kwargs) -> Deployment:
|
|
302
|
+
data = self._put(f"/api/v1/deployments/{deployment_id}", kwargs)
|
|
303
|
+
return self._wrap(Deployment, data)
|
|
304
|
+
|
|
305
|
+
# ── Principals ──
|
|
306
|
+
|
|
307
|
+
def create_principal(self, external_id: str, name: str = None, email: str = None,
|
|
308
|
+
type: str = "human", source: str = None) -> Principal:
|
|
309
|
+
data = self._post("/api/v1/principals", {
|
|
310
|
+
"external_id": external_id, "name": name, "email": email,
|
|
311
|
+
"type": type, "source": source,
|
|
312
|
+
})
|
|
313
|
+
return self._wrap(Principal, data)
|
|
314
|
+
|
|
315
|
+
def principals(self, source: str = None) -> List[dict]:
|
|
316
|
+
params = {"source": source} if source else {}
|
|
317
|
+
return self._get("/api/v1/principals", **params)
|
|
318
|
+
|
|
319
|
+
# ── Resolve (runtime) ──
|
|
320
|
+
|
|
321
|
+
def resolve(self, agent_name: str, project_id: int = None,
|
|
322
|
+
user_id: int = None, trace_id: str = None) -> Decision:
|
|
323
|
+
data = self._post("/api/v1/resolve", {
|
|
324
|
+
"agent_name": agent_name, "project_id": project_id,
|
|
325
|
+
"user_id": user_id, "trace_id": trace_id,
|
|
326
|
+
})
|
|
327
|
+
return self._wrap(Decision, data)
|
|
328
|
+
|
|
329
|
+
def resolve_failover(self, failed_key: str, agent_name: str = None,
|
|
330
|
+
reason: str = None, trace_id: str = None,
|
|
331
|
+
user_id: int = None,
|
|
332
|
+
allowed_hubs: List[str] = None,
|
|
333
|
+
denied_hubs: List[str] = None) -> dict:
|
|
334
|
+
"""Find a replacement for a deployment that failed.
|
|
335
|
+
|
|
336
|
+
Pass `agent_name` — with it the replacement is chosen under the same
|
|
337
|
+
governance policy as the original decision and the failover is
|
|
338
|
+
recorded in the audit trail. Without it the call falls back to a plain
|
|
339
|
+
hub allowlist and writes no audit record.
|
|
340
|
+
"""
|
|
341
|
+
return self._post("/api/v1/resolve/failover", {
|
|
342
|
+
"failed_deployment_key": failed_key,
|
|
343
|
+
"agent_name": agent_name, "reason": reason,
|
|
344
|
+
"trace_id": trace_id, "user_id": user_id,
|
|
345
|
+
"allowed_hubs": allowed_hubs, "denied_hubs": denied_hubs,
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
# ── Audit ──
|
|
349
|
+
|
|
350
|
+
def audit(self, routing_log_id: int, input_tokens: int = 0,
|
|
351
|
+
output_tokens: int = 0, latency_ms: int = None,
|
|
352
|
+
model_key: str = None) -> dict:
|
|
353
|
+
return self._post("/api/v1/audit", {
|
|
354
|
+
"routing_log_id": routing_log_id, "input_tokens": input_tokens,
|
|
355
|
+
"output_tokens": output_tokens, "latency_ms": latency_ms,
|
|
356
|
+
"model_key": model_key,
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
# ── Explain ──
|
|
360
|
+
|
|
361
|
+
def explain(self, routing_log_id: int) -> dict:
|
|
362
|
+
return self._get(f"/api/v1/explain/{routing_log_id}")
|
|
363
|
+
|
|
364
|
+
# ── Simulate ──
|
|
365
|
+
|
|
366
|
+
def simulate(self, project_id: int, quality: int = None, cost: int = None,
|
|
367
|
+
risk: int = None, agent_name: str = None, **kwargs) -> dict:
|
|
368
|
+
payload = {"project_id": project_id, **kwargs}
|
|
369
|
+
if agent_name: payload["agent_name"] = agent_name
|
|
370
|
+
if quality is not None: payload["quality"] = quality
|
|
371
|
+
if cost is not None: payload["cost"] = cost
|
|
372
|
+
if risk is not None: payload["risk"] = risk
|
|
373
|
+
return self._post(f"/api/v1/projects/{project_id}/simulate", payload)
|
|
374
|
+
|
|
375
|
+
# ── Change Requests ──
|
|
376
|
+
|
|
377
|
+
def create_change_request(self, project_id: int, requested_by: int = None,
|
|
378
|
+
agent_name: str = None, **kwargs) -> ChangeRequest:
|
|
379
|
+
payload = {"agent_name": agent_name, "requested_by": requested_by, **kwargs}
|
|
380
|
+
data = self._post(f"/api/v1/projects/{project_id}/change-requests", payload)
|
|
381
|
+
return self._wrap(ChangeRequest, data)
|
|
382
|
+
|
|
383
|
+
def approve_change_request(self, cr_id: int, reviewed_by: int = None,
|
|
384
|
+
notes: str = None) -> dict:
|
|
385
|
+
return self._post(f"/api/v1/change-requests/{cr_id}/approve",
|
|
386
|
+
{"reviewed_by": reviewed_by, "notes": notes})
|
|
387
|
+
|
|
388
|
+
def reject_change_request(self, cr_id: int, reviewed_by: int = None,
|
|
389
|
+
notes: str = None) -> dict:
|
|
390
|
+
return self._post(f"/api/v1/change-requests/{cr_id}/reject",
|
|
391
|
+
{"reviewed_by": reviewed_by, "notes": notes})
|
|
392
|
+
|
|
393
|
+
def change_requests(self, project_id: int = None, status: str = None) -> List[dict]:
|
|
394
|
+
params = {}
|
|
395
|
+
if project_id: params["project_id"] = project_id
|
|
396
|
+
if status: params["status"] = status
|
|
397
|
+
return self._get("/api/v1/change-requests", **params)
|
|
398
|
+
|
|
399
|
+
# ── Catalog / Config ──
|
|
400
|
+
|
|
401
|
+
def catalog(self) -> List[dict]:
|
|
402
|
+
return self._get("/api/v1/catalog")
|
|
403
|
+
|
|
404
|
+
def config(self) -> dict:
|
|
405
|
+
return self._get("/api/v1/config")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Meridian SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MeridianError(Exception):
|
|
5
|
+
"""Base exception for Meridian SDK."""
|
|
6
|
+
def __init__(self, message: str, status_code: int = None, detail: dict = None):
|
|
7
|
+
super().__init__(message)
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
self.detail = detail
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NotFoundError(MeridianError):
|
|
13
|
+
"""Resource not found (404)."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConflictError(MeridianError):
|
|
18
|
+
"""Resource already exists (409)."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValidationError(MeridianError):
|
|
23
|
+
"""Invalid configuration — e.g. zero candidates survive (422)."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ServerError(MeridianError):
|
|
28
|
+
"""Meridian server error (5xx)."""
|
|
29
|
+
pass
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Meridian SDK response models — typed responses, not raw dicts.
|
|
2
|
+
|
|
3
|
+
Each dataclass lists the fields the server is known to return as of this
|
|
4
|
+
SDK version. `extra` catches anything the server adds later that this SDK
|
|
5
|
+
doesn't know about yet — `client.py`'s `_wrap()` routes unmatched keys there
|
|
6
|
+
instead of silently dropping them, which is what used to happen (a v1 SDK
|
|
7
|
+
build that pre-dated allowed_hubs/denied_hubs/agent_type on Agent returned
|
|
8
|
+
them with those fields missing from the object with no indication anything
|
|
9
|
+
was lost). Check `.extra` if a field you expect is missing from the typed
|
|
10
|
+
attributes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Optional, List, Dict, Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Org:
|
|
19
|
+
id: int
|
|
20
|
+
name: str
|
|
21
|
+
default_quality: int = 5
|
|
22
|
+
default_cost: int = 5
|
|
23
|
+
default_risk: int = 5
|
|
24
|
+
subscription_tier: str = "standard"
|
|
25
|
+
monthly_budget_usd: Optional[float] = None
|
|
26
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Project:
|
|
31
|
+
id: int
|
|
32
|
+
org_id: int
|
|
33
|
+
name: str
|
|
34
|
+
display_name: Optional[str] = None
|
|
35
|
+
project_type: Optional[str] = None
|
|
36
|
+
lifecycle: Optional[str] = None
|
|
37
|
+
quality_default: Optional[int] = None
|
|
38
|
+
cost_default: Optional[int] = None
|
|
39
|
+
risk_default: Optional[int] = None
|
|
40
|
+
data_classification: str = "general"
|
|
41
|
+
allowed_models: Optional[List[str]] = None
|
|
42
|
+
denied_models: Optional[List[str]] = None
|
|
43
|
+
allowed_hubs: Optional[List[str]] = None
|
|
44
|
+
denied_hubs: Optional[List[str]] = None
|
|
45
|
+
monthly_budget_usd: Optional[float] = None
|
|
46
|
+
enabled: bool = True
|
|
47
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Agent:
|
|
52
|
+
# No `id` — project_agents has no id column at all (its primary key is
|
|
53
|
+
# the (project_id, agent_name) pair; the same fact that made the old
|
|
54
|
+
# DELETE ... RETURNING id 500 on every call, fixed earlier in meridian's
|
|
55
|
+
# own repo). A dataclass with `id: int` required meant every real
|
|
56
|
+
# create_agent() call raised trying to construct it — caught only once
|
|
57
|
+
# something actually called it end to end, here, building the CLI.
|
|
58
|
+
project_id: int
|
|
59
|
+
agent_name: str
|
|
60
|
+
display_name: Optional[str] = None
|
|
61
|
+
agent_type: Optional[str] = None
|
|
62
|
+
quality_override: Optional[int] = None
|
|
63
|
+
cost_override: Optional[int] = None
|
|
64
|
+
risk_override: Optional[int] = None
|
|
65
|
+
pinned_model: Optional[str] = None
|
|
66
|
+
data_classification: Optional[str] = None
|
|
67
|
+
system_prompt: Optional[str] = None
|
|
68
|
+
allowed_hubs: Optional[List[str]] = None
|
|
69
|
+
denied_hubs: Optional[List[str]] = None
|
|
70
|
+
enabled: bool = True
|
|
71
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Hub:
|
|
76
|
+
id: int
|
|
77
|
+
name: str
|
|
78
|
+
hub_type: str
|
|
79
|
+
org_id: Optional[int] = None
|
|
80
|
+
base_url: Optional[str] = None
|
|
81
|
+
region: Optional[str] = None
|
|
82
|
+
risk_score: int = 5
|
|
83
|
+
enabled: bool = True
|
|
84
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Model:
|
|
89
|
+
id: int
|
|
90
|
+
name: str
|
|
91
|
+
provider: str
|
|
92
|
+
display_name: Optional[str] = None
|
|
93
|
+
family: Optional[str] = None
|
|
94
|
+
quality_tier: float = 5
|
|
95
|
+
supports_tools: bool = False
|
|
96
|
+
supports_vision: bool = False
|
|
97
|
+
supports_streaming: bool = True
|
|
98
|
+
context_window: Optional[int] = None
|
|
99
|
+
model_type: str = "chat"
|
|
100
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Deployment:
|
|
105
|
+
id: int
|
|
106
|
+
model_id: int
|
|
107
|
+
hub_id: int
|
|
108
|
+
deployment_key: str
|
|
109
|
+
backend_model_id: Optional[str] = None
|
|
110
|
+
cost_per_1m_input: float = 0
|
|
111
|
+
cost_per_1m_output: float = 0
|
|
112
|
+
latency_ms: Optional[int] = None
|
|
113
|
+
risk_score: Optional[int] = None
|
|
114
|
+
depreciation_factor: Optional[float] = None
|
|
115
|
+
enabled: bool = True
|
|
116
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class ApiKey:
|
|
121
|
+
id: int
|
|
122
|
+
org_id: int
|
|
123
|
+
key_prefix: str
|
|
124
|
+
name: Optional[str] = None
|
|
125
|
+
created_at: Optional[str] = None
|
|
126
|
+
last_used_at: Optional[str] = None
|
|
127
|
+
enabled: bool = True
|
|
128
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Principal:
|
|
133
|
+
id: int
|
|
134
|
+
external_id: str
|
|
135
|
+
name: Optional[str] = None
|
|
136
|
+
email: Optional[str] = None
|
|
137
|
+
type: str = "human"
|
|
138
|
+
source: Optional[str] = None
|
|
139
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class Decision:
|
|
144
|
+
model: Optional[str] = None
|
|
145
|
+
hub: Optional[str] = None
|
|
146
|
+
source: str = "scored"
|
|
147
|
+
routing_log_id: Optional[int] = None
|
|
148
|
+
project_id: Optional[int] = None
|
|
149
|
+
project_name: Optional[str] = None
|
|
150
|
+
sliders: Optional[Dict[str, int]] = None
|
|
151
|
+
candidates_evaluated: Optional[List[Dict]] = None
|
|
152
|
+
eliminated: Optional[List[Dict]] = None
|
|
153
|
+
data_classification: Optional[str] = None
|
|
154
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class ChangeRequest:
|
|
159
|
+
id: int
|
|
160
|
+
project_id: int
|
|
161
|
+
status: str = "pending"
|
|
162
|
+
field_changes: Optional[Dict] = None
|
|
163
|
+
resolved_model_before: Optional[str] = None
|
|
164
|
+
resolved_model_after: Optional[str] = None
|
|
165
|
+
applied_fields: Optional[List[str]] = None
|
|
166
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meridian-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Meridian Governance Router
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/knjpollard/meridian
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: requests>=2.28.0
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
name: meridian-integration
|
|
15
|
+
description: How to call Meridian (the governance router) correctly from a new, standalone client — auth, the onboarding sequence, and what's still house/console-only.
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Integrating with Meridian
|
|
19
|
+
|
|
20
|
+
Meridian is a policy-governed inference router: you ask it "which model should
|
|
21
|
+
`agent_name` use right now", and it answers using project/agent policy
|
|
22
|
+
(quality, cost, risk, allowed/denied hubs, data classification) rather than
|
|
23
|
+
you hardcoding a model name. It knows nothing about MTA specifically — any
|
|
24
|
+
client that holds a valid key can use it standalone.
|
|
25
|
+
|
|
26
|
+
Full generated route-by-route reference: `docs/api/api-reference.md` in this
|
|
27
|
+
repo (regenerate with `python3 scripts/gen_api_docs.py` after any route
|
|
28
|
+
change — it reads the live FastAPI app, so it can't go stale the way a
|
|
29
|
+
hand-written doc can).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install meridian-client # published — the normal path, or
|
|
35
|
+
pip install -e meridian-client/ # editable, from this repo (contributing to the SDK itself)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Prefer a terminal to Python? `pip install meridian-governance-cli` wraps
|
|
39
|
+
this same SDK as subcommands (`meridian signup`, `meridian resolve
|
|
40
|
+
my_agent`, `meridian keys list`, ...) — installs as the `meridian` command;
|
|
41
|
+
see `meridian-cli/README.md` (not to be confused with the unrelated
|
|
42
|
+
`meridian-cli` package also on PyPI). No chat loop, no personas; it's a
|
|
43
|
+
management CLI over the API, not a clone of `mta-cli`'s consumer-companion
|
|
44
|
+
command set.
|
|
45
|
+
|
|
46
|
+
## Auth — read this before anything else
|
|
47
|
+
|
|
48
|
+
Meridian accepts a key two ways; it doesn't matter which:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
Authorization: Bearer mdn_xxxxxxxx
|
|
52
|
+
x-api-key: mdn_xxxxxxxx
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With no key at all: **on-prem only**, and only if that instance was started
|
|
56
|
+
without `MERIDIAN_AUTH_REQUIRED=true`. The request resolves to the instance's
|
|
57
|
+
single default org. Every cloud deployment (`dev-meridian.mytrustworthy.ai`
|
|
58
|
+
and any other `MERIDIAN_DEPLOYMENT=cloud` instance) requires a key — no key
|
|
59
|
+
gets a flat 401, not a default org.
|
|
60
|
+
|
|
61
|
+
## Getting your first key
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from meridian import Meridian
|
|
65
|
+
|
|
66
|
+
m = Meridian.signup("https://dev-meridian.mytrustworthy.ai",
|
|
67
|
+
org_name="my-project", email="me@example.com", password="...")
|
|
68
|
+
# m is now an authenticated client. m.org_id and m.signup_response are set.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
One call: creates a new org (always `free` tier — self-serve can't grant
|
|
72
|
+
itself more than that), its first user, and its first key, and returns a
|
|
73
|
+
`Meridian` already authenticated with it. This is the *only* unauthenticated
|
|
74
|
+
write on the whole API — everywhere else, minting a key or registering a
|
|
75
|
+
user needs an `org_id` that a cold caller doesn't have yet, which is exactly
|
|
76
|
+
the chicken-and-egg problem this one call exists to break.
|
|
77
|
+
|
|
78
|
+
If you already have a key (an account team provisioned you directly, or
|
|
79
|
+
you're adding a second client to an org that already signed up):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
m = Meridian("https://dev-meridian.mytrustworthy.ai", api_key="mdn_...")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Three tiers, one engine
|
|
86
|
+
|
|
87
|
+
There's one governance engine underneath all of this — the tiers below are
|
|
88
|
+
just how much of it you choose to use, not three different products:
|
|
89
|
+
|
|
90
|
+
- **Individual / fast start:** pin a model and go. `pinned_model` skips
|
|
91
|
+
scoring entirely (`resolver.py`'s "Step 3: Pinned model — skip scoring") —
|
|
92
|
+
deterministic, zero policy to configure.
|
|
93
|
+
- **Business / small team:** the quality/cost/risk sliders. Same `create_agent`
|
|
94
|
+
call, just set overrides instead of (or in addition to) a pin; invite
|
|
95
|
+
teammates into the same org via `POST /api/v1/auth/register` once you're
|
|
96
|
+
authenticated.
|
|
97
|
+
- **Enterprise / custom governance:** change-requests and approval
|
|
98
|
+
(`create_change_request` → `approve_change_request`), hub allow/denylists,
|
|
99
|
+
data classification enforcement (`pii` hard-forces risk to 1, on-prem only).
|
|
100
|
+
All already built; nothing here is Enterprise-only code, just the parts a
|
|
101
|
+
fast-start integration doesn't need to touch.
|
|
102
|
+
|
|
103
|
+
## The onboarding sequence
|
|
104
|
+
|
|
105
|
+
A tenant key can do all of this itself — no console needed past signup:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# 1. A project: your governance boundary. One org can have many.
|
|
109
|
+
project = m.create_project(
|
|
110
|
+
org_id=m.org_id,
|
|
111
|
+
name="my-standalone-agent",
|
|
112
|
+
quality=7, cost=5, risk=5, # 0-10 sliders, project-level defaults
|
|
113
|
+
classification="general", # "pii" forces risk=1 regardless of the slider
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# 2. An agent inside it. This is the identity you resolve against — not a
|
|
117
|
+
# user, not an API key, an AGENT. One project can have many, each with its
|
|
118
|
+
# own overrides.
|
|
119
|
+
agent = m.create_agent(
|
|
120
|
+
project.id, "my_agent",
|
|
121
|
+
quality_override=9, # omit to inherit the project default
|
|
122
|
+
allowed_hubs=["anthropic-direct", "bedrock"], # omit for no hub restriction
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# 3. Resolve. Call this every time you're about to make an inference call —
|
|
126
|
+
# it's cheap, and policy can change between calls.
|
|
127
|
+
decision = m.resolve("my_agent", project_id=project.id)
|
|
128
|
+
print(decision.model, decision.hub) # e.g. "claude-opus-5" "anthropic-direct"
|
|
129
|
+
|
|
130
|
+
# 4. After the real inference call, report usage so cost tracking means
|
|
131
|
+
# something.
|
|
132
|
+
m.audit(decision.routing_log_id, input_tokens=512, output_tokens=128,
|
|
133
|
+
latency_ms=840)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
That's the whole golden path. `m.explain(decision.routing_log_id)` gets you
|
|
137
|
+
the full candidate breakdown (every model considered, why each one was
|
|
138
|
+
eliminated or scored where it scored) if you need to show your work.
|
|
139
|
+
|
|
140
|
+
## Without the SDK (raw HTTP)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
curl -X POST https://dev-meridian.mytrustworthy.ai/api/v1/resolve \
|
|
144
|
+
-H "Authorization: Bearer mdn_..." -H "Content-Type: application/json" \
|
|
145
|
+
-d '{"agent_name": "my_agent", "project_id": 123}'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Every route in `docs/api/api-reference.md` works the same way — method, path,
|
|
149
|
+
JSON body of the fields listed, same two auth headers.
|
|
150
|
+
|
|
151
|
+
## What a tenant key cannot do (yet)
|
|
152
|
+
|
|
153
|
+
`docs/api/api-reference.md` marks every route **tenant key: yes/no**. The
|
|
154
|
+
"no" routes answer `403 "This route is not yet tenant-scoped; refused for
|
|
155
|
+
tenant keys"` regardless of whether your key is valid — it's a blanket
|
|
156
|
+
refusal by path, not a permission check. As of this writing that's mainly:
|
|
157
|
+
hub/model/deployment registration and edits, org creation and edits, and most
|
|
158
|
+
cost/governance console views. Those need a house key or the console. Project
|
|
159
|
+
and agent create/update/delete, resolve, audit, explain, simulate, and
|
|
160
|
+
change-requests are all tenant-reachable.
|
|
161
|
+
|
|
162
|
+
## Gotchas
|
|
163
|
+
|
|
164
|
+
- **`PUT /api/v1/projects/{id}/lifecycle` is a stub.** It validates the
|
|
165
|
+
request and echoes the current row back — it does not change anything.
|
|
166
|
+
Don't build on it (`meridian#7`'s predecessor finding).
|
|
167
|
+
- **The SDK's response objects have an `.extra` dict.** Every typed field the
|
|
168
|
+
SDK knows about is a real attribute (`agent.allowed_hubs`); anything the
|
|
169
|
+
server returns that this SDK version doesn't model yet lands in
|
|
170
|
+
`agent.extra` instead of silently vanishing. If a field you expect is
|
|
171
|
+
`None`, check `.extra` before assuming the server didn't send it.
|
|
172
|
+
- **`resolve_failover` only writes an audit record if you pass
|
|
173
|
+
`agent_name`.** Without it you get a plain hub-allowlist fallback with no
|
|
174
|
+
governance and no audit trail — fine for "just get me anything that
|
|
175
|
+
works", wrong for anything that needs to explain itself later.
|
|
176
|
+
- **Tool-access governance does not exist yet.** Meridian governs *which
|
|
177
|
+
model* an agent may use. It does not yet govern *which tools* an agent may
|
|
178
|
+
call — there's no route for it, and the schema slots for it
|
|
179
|
+
(`agent.agent_tools`, `agent.skill_ids`) are unpopulated. If your new agent
|
|
180
|
+
needs Meridian to gate tool access rather than just model choice, that's a
|
|
181
|
+
real gap, not a documentation gap — see
|
|
182
|
+
`obsidian-vault-kb/relays/meridian/2026-10-12-should-meridian-govern-tools-and-what-that-costs-in-apis.md`
|
|
183
|
+
for the live design discussion.
|
|
184
|
+
- **Forgot a built-in-auth password? `POST /auth/forgot-password` /
|
|
185
|
+
`POST /auth/reset-password`** (meridian#10), also reachable from the
|
|
186
|
+
console's "Forgot password?" link. Requires `RESEND_API_KEY` to actually
|
|
187
|
+
be configured on the instance — without it the request fails loudly
|
|
188
|
+
rather than handing the token back in the response. That's deliberate:
|
|
189
|
+
an earlier version of this capability did exactly that and was removed on
|
|
190
|
+
2026-09-12 because it let anyone who knew an email address take over that
|
|
191
|
+
account. There is no magic-link *login* — only password reset; you still
|
|
192
|
+
need to know a password to sign in, the email only proves you own the
|
|
193
|
+
inbox.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
SKILL.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
meridian/__init__.py
|
|
6
|
+
meridian/client.py
|
|
7
|
+
meridian/exceptions.py
|
|
8
|
+
meridian/models.py
|
|
9
|
+
meridian_client.egg-info/PKG-INFO
|
|
10
|
+
meridian_client.egg-info/SOURCES.txt
|
|
11
|
+
meridian_client.egg-info/dependency_links.txt
|
|
12
|
+
meridian_client.egg-info/requires.txt
|
|
13
|
+
meridian_client.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
meridian
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "meridian-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for Meridian Governance Router"
|
|
5
|
+
readme = "SKILL.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
dependencies = ["requests>=2.28.0"]
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Homepage = "https://github.com/knjpollard/meridian"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["setuptools>=68.0"]
|
|
15
|
+
build-backend = "setuptools.build_meta"
|