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.
@@ -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
+ requests>=2.28.0
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+