flatland-client 0.1.0__py3-none-any.whl

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,49 @@
1
+ """flatland-client — the durable, dependency-free Python client for hosted Flatland.
2
+
3
+ Option C: your model is a file YOU own (``~/.flatland/models/<name>.flatland.json``),
4
+ versioned in your own git, diffable, re-runnable anywhere. The hosted server is a
5
+ pure function (``/api/v2/*``): the client ships the IR in, the server
6
+ computes/transforms and returns, and retains NOTHING at rest.
7
+
8
+ This is the SAME durability mechanism as the in-engine reference
9
+ ``flatland.client`` (kept byte-for-byte in semantics), repackaged as a thin,
10
+ stdlib-only install so a Python user gets the durable metered path WITHOUT
11
+ pulling in the full engine (fastapi/uvicorn/stripe/...). It talks to the public
12
+ hosted ``/api/v2`` surface over HTTPS with the user's ``X-API-Key``; auth +
13
+ metering + the server-derived tenant key all apply.
14
+
15
+ Quickstart::
16
+
17
+ from flatland_client import create, load, http_transport
18
+ t = http_transport(api_key="fl_live_...")
19
+ h = create("acme_plan", t) # writes ~/.flatland/models/acme_plan.flatland.json
20
+ h.bulk_add([{"name": "revenue", "type": "Currency", "value": 1000}])
21
+ print(h.compile()["values"]) # metered compute, nothing stored server-side
22
+ # --- new session / new machine that has the file ---
23
+ h = load("acme_plan", t) # durable across restart
24
+ """
25
+
26
+ from .client import (
27
+ DEFAULT_CLIENT_STORE_DIR,
28
+ PORTABLE_IR_VERSION,
29
+ FlatlandModelFile,
30
+ FlatlandMutationError,
31
+ Transport,
32
+ create,
33
+ http_transport,
34
+ load,
35
+ )
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "Transport",
41
+ "http_transport",
42
+ "FlatlandModelFile",
43
+ "FlatlandMutationError",
44
+ "create",
45
+ "load",
46
+ "DEFAULT_CLIENT_STORE_DIR",
47
+ "PORTABLE_IR_VERSION",
48
+ "__version__",
49
+ ]
@@ -0,0 +1,347 @@
1
+ """Flatland thin client — the CLIENT-SIDE half of Option C (durability mechanism).
2
+
3
+ This is the load-bearing durability contract: **the customer's model is a file
4
+ THE CLIENT owns and persists locally, and it survives across sessions and process
5
+ restarts because it lives on the client's disk — never the server's.** The hosted
6
+ server is a pure function (``/api/v2/*``): the client ships the IR in, the server
7
+ computes/transforms and returns, and retains nothing.
8
+
9
+ This is the dependency-free, pip-packaged twin of the in-engine reference
10
+ ``flatland.client``. The semantics are identical; the only thing dropped is the
11
+ engine-coupled ``in_process_transport`` (a test/dev convenience that needs the
12
+ full engine) — a thin client install has nothing to call it against. Use
13
+ ``http_transport`` against the hosted ``/api/v2`` surface.
14
+
15
+ Where the file lives (the durability mechanism, spelled out):
16
+ * Default: ``~/.flatland/models/<name>.flatland.json`` ON THE CLIENT'S machine.
17
+ * Override: any path the caller chooses (a project dir, a git working tree).
18
+ The file is a plain JSON envelope (``PORTABLE_IR_VERSION`` + the IR). It is
19
+ exactly what a customer commits to their own git — versioned, diffable,
20
+ re-runnable anywhere. The server is never the system of record.
21
+
22
+ How it survives a restart: a mutation call (``add_driver`` / ``bulk_add`` / ...)
23
+ returns the NEW IR; the client writes it to the local file IMMEDIATELY (atomic
24
+ tmp+replace). A fresh process (new session, after a reboot, on another laptop
25
+ that has the file) calls :meth:`load` and continues exactly where it left off.
26
+
27
+ F2 (data-safety): a failed or 5xx mutation MUST raise and leave the local file
28
+ UNTOUCHED — never a silent stale-file "success".
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ from pathlib import Path
36
+ from typing import Any, Protocol
37
+
38
+ #: Wire-format version for the portable IR envelope. Kept in lockstep with the
39
+ #: server's ``portable.PORTABLE_IR_VERSION``. A client file tagged with an older
40
+ #: version must keep loading — the client-durability contract.
41
+ PORTABLE_IR_VERSION = 1
42
+
43
+ DEFAULT_CLIENT_STORE_DIR = Path.home() / ".flatland" / "models"
44
+ _FILE_SUFFIX = ".flatland.json"
45
+
46
+
47
+ class FlatlandMutationError(RuntimeError):
48
+ """A mutation call did not produce an updated model IR (F2).
49
+
50
+ Raised when the server rejected the mutation (an error response) or returned
51
+ no new IR. The client is the system-of-record, so a failed mutation must be
52
+ loud — the local file is left untouched and the caller cannot mistake the
53
+ failure for success. ``response`` carries the structured error body (if any)
54
+ for inspection."""
55
+
56
+ def __init__(self, message: str, *, response: dict[str, Any] | None = None):
57
+ super().__init__(message)
58
+ self.response = response
59
+
60
+
61
+ class Transport(Protocol):
62
+ """A function that POSTs a JSON body to a ``/api/v2/*`` path and returns the
63
+ decoded JSON response. The concrete HTTP implementation lives in
64
+ :func:`http_transport`."""
65
+
66
+ def __call__(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
67
+ """POST ``body`` to ``path`` and return the decoded JSON response."""
68
+
69
+
70
+ def http_transport(
71
+ api_key: str,
72
+ base_url: str = "https://api.flatlandfi.com",
73
+ timeout: float = 60.0,
74
+ ) -> Transport:
75
+ """Build an HTTPS transport for the hosted per-call surface.
76
+
77
+ Uses urllib (stdlib) so the client carries no third-party dependency. The
78
+ API key goes in the server-validated ``X-API-Key`` header — the tenant key
79
+ is derived server-side (sha256), never sent by the client.
80
+
81
+ ``timeout`` (seconds) bounds every request: without it a stalled connection
82
+ or a server that stops sending mid-response would block create/mutations/
83
+ compute FOREVER, and the caller would never receive the fail-loud error the
84
+ durable contract promises (Greptile #78). On timeout urllib raises (a
85
+ ``URLError`` wrapping ``socket.timeout``), which propagates exactly like a
86
+ 5xx/network failure — the local file is left untouched."""
87
+ import urllib.error
88
+ import urllib.request
89
+
90
+ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
91
+ url = base_url.rstrip("/") + path
92
+ data = json.dumps(body).encode("utf-8")
93
+ req = urllib.request.Request(
94
+ url,
95
+ data=data,
96
+ headers={"Content-Type": "application/json", "X-API-Key": api_key},
97
+ method="POST",
98
+ )
99
+ try:
100
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — https hosted API
101
+ return json.loads(resp.read().decode("utf-8"))
102
+ except urllib.error.HTTPError as exc:
103
+ # EXPECTED 4xx (400 bad IR, 402 billing-gate, 413 too-large) carry a
104
+ # STRUCTURED JSON body — decode it and tag the status so the caller
105
+ # can branch (e.g. 402 = quota). A rejected mutation must be
106
+ # distinguishable from a network failure.
107
+ #
108
+ # A 5xx is DIFFERENT and MUST RAISE (F2). The client is the
109
+ # system-of-record: a 5xx means the server failed to process the
110
+ # mutation, so there is NO trustworthy new IR. Returning a decoded
111
+ # 5xx body would let ``_apply`` treat a failed mutation as success
112
+ # (no ``ir`` key → keeps the stale local file) — silent data loss.
113
+ if exc.code >= 500:
114
+ raise
115
+ raw = exc.read()
116
+ try:
117
+ payload = json.loads(raw.decode("utf-8"))
118
+ except (ValueError, UnicodeDecodeError):
119
+ raise
120
+ if isinstance(payload, dict):
121
+ payload.setdefault("http_status", exc.code)
122
+ return payload
123
+ raise
124
+
125
+ return _post
126
+
127
+
128
+ def _check_envelope_version(envelope: dict[str, Any], where: str) -> None:
129
+ """Reject an envelope this client is too old to handle, BEFORE persisting it.
130
+
131
+ Mixed-version safety: if the server returns a newer envelope than this client
132
+ supports, persisting it would write a file the same client cannot ``load()``
133
+ next session. Fail at receipt instead, with a clear upgrade message."""
134
+ version = envelope.get("flatland_ir_version", PORTABLE_IR_VERSION)
135
+ if not isinstance(version, int) or version > PORTABLE_IR_VERSION:
136
+ raise ValueError(
137
+ f"{where}: server returned flatland_ir_version {version}; this "
138
+ f"client supports up to {PORTABLE_IR_VERSION}. Upgrade flatland-client "
139
+ f"before persisting this model file."
140
+ )
141
+
142
+
143
+ class FlatlandModelFile:
144
+ """A client-owned model file + the pure-function calls that operate on it.
145
+
146
+ Hold one of these per model. Compute calls ship the local IR and return
147
+ results without changing the file. Mutation calls ship the local IR, receive
148
+ the new IR, and PERSIST it locally so the change survives the next restart.
149
+ """
150
+
151
+ def __init__(self, path: Path | str, transport: Transport, ir: dict[str, Any]):
152
+ self.path = Path(path)
153
+ self._transport = transport
154
+ self._ir = ir # the portable envelope {flatland_ir_version, ir:{...}}
155
+
156
+ @property
157
+ def ir(self) -> dict[str, Any]:
158
+ """The current in-memory IR envelope (mirror of the on-disk file)."""
159
+ return self._ir
160
+
161
+ def save(self) -> Path:
162
+ """Atomically write the current IR to the local file (tmp + replace), so
163
+ a restart loses nothing. Returns the file path."""
164
+ self.path.parent.mkdir(parents=True, exist_ok=True)
165
+ tmp = self.path.with_suffix(self.path.suffix + f".tmp.{os.getpid()}")
166
+ tmp.write_text(json.dumps(self._ir, indent=2))
167
+ # 0600: the model is the user's sensitive IP.
168
+ try:
169
+ os.chmod(tmp, 0o600)
170
+ except OSError:
171
+ # Best-effort: some filesystems (e.g. Windows/FAT) don't support
172
+ # POSIX modes. Persistence must still proceed for durability, so we
173
+ # swallow the chmod failure rather than abort the save.
174
+ pass
175
+ os.replace(tmp, self.path)
176
+ return self.path
177
+
178
+ def _apply(self, response: dict[str, Any]) -> dict[str, Any]:
179
+ """Adopt + persist a successful mutation's new IR locally; raise on a
180
+ failed mutation so it is never silently mistaken for success (F2)."""
181
+ if isinstance(response, dict) and isinstance(response.get("ir"), dict):
182
+ _check_envelope_version(response["ir"], "mutation")
183
+ self._ir = response["ir"]
184
+ self.save()
185
+ return {k: v for k, v in response.items() if k != "ir"}
186
+ if isinstance(response, dict) and response.get("error"):
187
+ raise FlatlandMutationError(
188
+ response.get("message") or str(response.get("error")),
189
+ response=response,
190
+ )
191
+ raise FlatlandMutationError(
192
+ "Mutation returned no updated model IR; the local file was not "
193
+ "changed.",
194
+ response=response if isinstance(response, dict) else None,
195
+ )
196
+
197
+ # -- compute (pure; file unchanged) -------------------------------------
198
+
199
+ def compile(self, scenario: str = "base") -> dict[str, Any]:
200
+ return self._transport("/api/v2/compile", {"ir": self._ir, "scenario": scenario})
201
+
202
+ def sensitivity(self, target: str, perturbation: float = 0.10, scenario: str = "base") -> dict[str, Any]:
203
+ return self._transport("/api/v2/sensitivity", {
204
+ "ir": self._ir, "target": target, "perturbation": perturbation, "scenario": scenario,
205
+ })
206
+
207
+ def diff(self, from_scenario: str, to_scenario: str, target: str | None = None) -> dict[str, Any]:
208
+ return self._transport("/api/v2/diff", {
209
+ "ir": self._ir, "from_scenario": from_scenario, "to_scenario": to_scenario, "target": target,
210
+ })
211
+
212
+ def explain(self, driver: str, max_depth: int = 5) -> dict[str, Any]:
213
+ return self._transport("/api/v2/explain", {
214
+ "ir": self._ir, "driver": driver, "max_depth": max_depth,
215
+ })
216
+
217
+ def impact_preview(self, proposed_changes: list[dict[str, Any]], target_drivers: list[str] | None = None) -> dict[str, Any]:
218
+ return self._transport("/api/v2/impact_preview", {
219
+ "ir": self._ir, "proposed_changes": proposed_changes, "target_drivers": target_drivers,
220
+ })
221
+
222
+ # -- mutations (ship IR, receive new IR, persist locally) ---------------
223
+
224
+ def bulk_add(self, drivers: list[dict[str, Any]]) -> dict[str, Any]:
225
+ return self._apply(self._transport("/api/v2/bulk_add", {"ir": self._ir, "drivers": drivers}))
226
+
227
+ def add_driver(self, **kwargs: Any) -> dict[str, Any]:
228
+ return self._apply(self._transport("/api/v2/add_driver", {"ir": self._ir, **kwargs}))
229
+
230
+ def add_computed(self, **kwargs: Any) -> dict[str, Any]:
231
+ return self._apply(self._transport("/api/v2/add_computed", {"ir": self._ir, **kwargs}))
232
+
233
+ def update_driver(self, **kwargs: Any) -> dict[str, Any]:
234
+ return self._apply(self._transport("/api/v2/update_driver", {"ir": self._ir, **kwargs}))
235
+
236
+ def remove_driver(self, name: str, cascade: bool = False) -> dict[str, Any]:
237
+ return self._apply(self._transport("/api/v2/remove_driver", {
238
+ "ir": self._ir, "name": name, "cascade": cascade,
239
+ }))
240
+
241
+ def create_scenario(self, name: str, overrides: dict[str, Any], description: str = "") -> dict[str, Any]:
242
+ return self._apply(self._transport("/api/v2/create_scenario", {
243
+ "ir": self._ir, "name": name, "overrides": overrides, "description": description,
244
+ }))
245
+
246
+
247
+ def _assert_safe_name(name: str) -> str:
248
+ """Validate a bare model name as a safe single path segment (path-traversal
249
+ defense — the name is untrusted). A dotted name ("acme.v2") is fine; a
250
+ separator, ``..``, leading dot, or NUL is not."""
251
+ if not isinstance(name, str) or not name.strip():
252
+ raise ValueError("Model name must be a non-empty string.")
253
+ n = name.strip()
254
+ if n in (".", ".."):
255
+ raise ValueError(f"Model name {n!r} is not allowed.")
256
+ if "/" in n or "\\" in n or "\0" in n:
257
+ raise ValueError(f"Model name {n!r} may not contain path separators.")
258
+ # Reject a name already ending in the reserved suffix: create() would save
259
+ # ``report.flatland.json`` as ``report.flatland.json.flatland.json`` while
260
+ # load() (which treats a suffixed arg as a path) would resolve a different
261
+ # file — the durable model would appear missing (Greptile #78).
262
+ if n.endswith(_FILE_SUFFIX):
263
+ raise ValueError(
264
+ f"Model name {n!r} may not end with the reserved {_FILE_SUFFIX!r} "
265
+ f"suffix; pass a bare name (the suffix is added for you)."
266
+ )
267
+ if n.startswith("."):
268
+ raise ValueError(f"Model name {n!r} may not start with a dot.")
269
+ return n
270
+
271
+
272
+ def _path_for(name: str, store_dir: Path | str | None) -> Path:
273
+ base = Path(store_dir) if store_dir is not None else DEFAULT_CLIENT_STORE_DIR
274
+ return base / f"{_assert_safe_name(name)}{_FILE_SUFFIX}"
275
+
276
+
277
+ def create(
278
+ name: str,
279
+ transport: Transport,
280
+ *,
281
+ store_dir: Path | str | None = None,
282
+ currency: str = "USD",
283
+ period_start: str = "2026-01",
284
+ period_end: str = "2026-12",
285
+ grain: str = "monthly",
286
+ description: str = "",
287
+ path: Path | str | None = None,
288
+ ) -> FlatlandModelFile:
289
+ """Create a new model on the server (pure call), persist its IR to a LOCAL
290
+ file the client owns, and return a handle. The file is the system of record
291
+ from here on; the server holds nothing."""
292
+ resp = transport("/api/v2/model/create", {
293
+ "name": name, "currency": currency, "period_start": period_start,
294
+ "period_end": period_end, "grain": grain, "description": description,
295
+ })
296
+ ir = resp.get("ir")
297
+ if not isinstance(ir, dict):
298
+ raise FlatlandMutationError(f"create did not return an IR envelope: {resp}", response=resp if isinstance(resp, dict) else None)
299
+ _check_envelope_version(ir, "create")
300
+ file_path = Path(path) if path is not None else _path_for(name, store_dir)
301
+ handle = FlatlandModelFile(file_path, transport, ir)
302
+ handle.save()
303
+ return handle
304
+
305
+
306
+ def load(
307
+ name_or_path: str | Path,
308
+ transport: Transport,
309
+ *,
310
+ store_dir: Path | str | None = None,
311
+ ) -> FlatlandModelFile:
312
+ """Load a model from the LOCAL file the client owns (durability across
313
+ sessions / restarts). Accepts a model name (resolved under ``store_dir``) or
314
+ a direct path. Raises ``FileNotFoundError`` if the client has no such file —
315
+ there is NO server fallback by design (the server never held it)."""
316
+ p = Path(name_or_path)
317
+ # Decide name-vs-path WITHOUT keying on ``.suffix`` (a valid model NAME may
318
+ # contain a dot). Treat the argument as a path only if it is unambiguously
319
+ # one: absolute, has a directory component, or carries the model-file suffix.
320
+ looks_like_path = (
321
+ p.is_absolute()
322
+ or p.parent != Path(".")
323
+ or str(name_or_path).endswith(_FILE_SUFFIX)
324
+ )
325
+ if not looks_like_path:
326
+ p = _path_for(str(name_or_path), store_dir)
327
+ if not p.exists():
328
+ raise FileNotFoundError(
329
+ f"No local model file at {p}. The client owns the model — there is "
330
+ f"no server copy to fall back to (Option C). Create it with "
331
+ f"flatland_client.create()."
332
+ )
333
+ envelope = json.loads(p.read_text())
334
+ _check_envelope_version(envelope, f"load {p}")
335
+ return FlatlandModelFile(p, transport, envelope)
336
+
337
+
338
+ __all__ = [
339
+ "Transport",
340
+ "http_transport",
341
+ "FlatlandModelFile",
342
+ "FlatlandMutationError",
343
+ "create",
344
+ "load",
345
+ "DEFAULT_CLIENT_STORE_DIR",
346
+ "PORTABLE_IR_VERSION",
347
+ ]
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: flatland-client
3
+ Version: 0.1.0
4
+ Summary: Durable, dependency-free Python client for the hosted Flatland engine. Your model is a file you own; the server is a pure function that holds nothing at rest.
5
+ Project-URL: Homepage, https://flatlandfi.com
6
+ Project-URL: Repository, https://github.com/Flatlandfi/flatland
7
+ Author-email: Flatland <info@flatlandfi.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: decision-modeling,financial-modeling,flatland,fpa,local-first
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Office/Business :: Financial
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # flatland-client
19
+
20
+ The durable, dependency-free Python client for the hosted [Flatland](https://flatlandfi.com) engine.
21
+
22
+ **Your model is a file you own.** It lives at `~/.flatland/models/<name>.flatland.json`
23
+ on *your* machine — versioned in your own git, diffable, re-runnable anywhere.
24
+ The hosted server is a pure function (`/api/v2/*`): the client ships the model IR
25
+ in, the server computes/transforms and returns, and **retains nothing at rest**.
26
+
27
+ This package is `pip install`-light: stdlib `urllib` only, no engine, no
28
+ fastapi/uvicorn/stripe. It is the supported durable, metered hosted path for
29
+ Python users.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install flatland-client
35
+ ```
36
+
37
+ ## Quickstart
38
+
39
+ ```python
40
+ from flatland_client import create, load, http_transport
41
+
42
+ t = http_transport(api_key="fl_live_...") # HTTPS to api.flatlandfi.com
43
+
44
+ h = create("acme_plan", t) # writes ~/.flatland/models/acme_plan.flatland.json
45
+ h.bulk_add([
46
+ {"name": "revenue", "type": "Currency", "value": 1000},
47
+ {"name": "cost", "type": "Currency", "value": 400},
48
+ {"name": "profit", "type": "Currency", "formula": "revenue - cost"},
49
+ ])
50
+ print(h.compile()["values"]["profit"]["value"]) # metered compute, nothing stored server-side
51
+
52
+ # --- new session / a new machine that has the file ---
53
+ h = load("acme_plan", t) # durable across restart
54
+ ```
55
+
56
+ ## Durability + safety contract
57
+
58
+ - **Atomic writes.** Every mutation persists the new IR with a tmp + `os.replace`
59
+ rename, so a crash never leaves a half-written model file.
60
+ - **Fail-loud (F2).** A failed mutation (a `5xx`, or a rejected `4xx`) raises and
61
+ **leaves the local file untouched** — a failure is never silently mistaken for
62
+ success.
63
+ - **No server fallback.** `load()` of a missing file raises — the server never
64
+ held your model, so it cannot supply one.
65
+ - **Envelope versioning.** The file is `{ "flatland_ir_version": 1, "ir": {...} }`.
66
+ A file written by an older client keeps loading; a too-new envelope is refused
67
+ before it is persisted.
68
+ - **Path-traversal-safe** model names.
69
+
70
+ Get a key at https://flatlandfi.com/install.
@@ -0,0 +1,6 @@
1
+ flatland_client/__init__.py,sha256=IAGobINxVsL8XsflN3g6PTLc0ktaXpLJwkolf8228O0,1748
2
+ flatland_client/client.py,sha256=cOLgy5LOhy61pTOA6Yn1xORadu-wN1os5jq7HW-zOVw,15680
3
+ flatland_client-0.1.0.dist-info/METADATA,sha256=GAVK6o_FrkiztUreD4a6atJ1rt7qS9K4nBeFPCCYsKg,2889
4
+ flatland_client-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ flatland_client-0.1.0.dist-info/licenses/LICENSE,sha256=d9kPorFrFdeE8m9-ulSAVKTHXDkO0c2MZMVtThRaV54,1528
6
+ flatland_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,30 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Isaac Lestienne
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.
22
+
23
+ ---
24
+
25
+ Note: This MIT license covers the flatland-agent terminal client only. The
26
+ Flatland engine and API (accessed at https://api.flatlandfi.com) are a
27
+ separate, proprietary service. "Flatland", "Flatland Agent", and the Flatland
28
+ marks are used as product identifiers by Isaac Lestienne; this MIT license does
29
+ not grant rights to the names, branding, or associated service. You bring your
30
+ own LLM provider key; inference is billed to you by your provider.