Metadata-Version: 2.5
Name: gridline
Version: 0.11.0
Summary: Name a role; configuration decides what serves it.
Project-URL: Homepage, https://www.get-gridline.dev
Project-URL: Documentation, https://www.get-gridline.dev
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Provides-Extra: yaml
Requires-Dist: pyyaml>=6; extra == 'yaml'
Description-Content-Type: text/markdown

# Gridline

Application code names a role. Configuration decides which model serves it, what it can
reach for, and what happens when a provider goes down.

There are two ways in, and the first one asks less of you.

---

## 1. Keep the client you already have

Gridline speaks the providers' own APIs and, on request, answers in their own bytes. So
point your existing client at it, name a Gridline agent in the model string, and stop.

```bash
curl https://gridline.internal/v1/messages \
  -H "authorization: Bearer $GRIDLINE_KEY" \
  -H "content-type: application/json" \
  -d '{
        "model": "gridline/invoice-reconciler",
        "format": "raw",
        "max_tokens": 1024,
        "messages": [{"role": "user", "content": "What did we agree about the VAT deadline?"}]
      }'
```

Three things are happening there:

- **`model: "gridline/<agent>"`** names the agent. The agent's configuration decides the
  real model and its fallbacks, so nothing in your code names a vendor.
- **`format: "raw"`** returns the provider's own response shape, and on an ordinary turn its exact bytes (a turn the proxy ran a tool loop for is assembled from several calls, so it is provider-shaped rather than byte-identical), so your SDK's
  parser keeps working. Leave it out and you get the canonical shape below instead —
  which is a third shape, and a strict client will reject it.
- **`authorization: Bearer`** is a Gridline route key, not a provider key. Provider
  credentials stay in your own secret manager and are resolved in the data plane.

The same request from the official Anthropic SDK, LangChain, or Vercel's
`@ai-sdk/anthropic` is a base URL and a model string. Three things to know before
shipping that route: **approvals are not renderable** on it, since a paused turn needs a
client that understands the pause — and no provider models one, so the body comes back
looking finished and only **`X-Approval-Required`** (the call ids, comma-joined) says
otherwise; **degradation arrives as headers** (`X-Route-Degraded`, `X-Session-Degraded`,
`X-Tools-Degraded`) that no provider SDK reads for you, so noticing it is your code's
job; and **a cross-vendor fallback changes the shape of the answer**, because raw means
the bytes of whoever served the turn. Where any of those matter, use this package
instead — including with `raw=True`, where it reads those headers for you and
`answer.awaiting_approval` works on the provider's own bytes.

---

## 2. Or use this library

For approvals, one response shape across every provider, and sessions handled for you.

```bash
uv pip install gridline
```

```python
import gridline

grid = gridline.connect("https://gridline.internal", key="gl_...")
chat = grid.session(
    agent="invoice-reconciler",
    assign={"memory": {"store": "acme-ledgers", "subject": "user-123"}},
)

answer = await chat.send("What did we agree about the VAT deadline?")
print(answer.text)
```

**It is asynchronous, and that is the default rather than the advanced option.** A turn
is almost entirely spent waiting on a model, and a turn that runs tools waits several
times, so blocking a thread for it is the wrong shape in anything serving requests.

If you want the blocking one, that is one argument and nothing else changes:

```python
grid = gridline.connect("https://gridline.internal", key="gl_...", sync=True)
answer = chat.send("What did we agree about the VAT deadline?")   # no await
```

Every other name is identical between them — `send`, `stream`, `resume`, the reply, and
`.text`/`.reply`/`.tool_calls`/`.session` on a stream — so moving a handler from one to
the other is adding or deleting `async` and `await`. That choice is usually made after
the code is written, and it should not be a rewrite when it is.

A session is a conversation, not a connection. It holds nothing the proxy does not also
hold, so `grid.session(agent=..., resume=answer.session)` in another process carries on
where this one stopped.

**One reply shape, whoever served it.** `answer.text`, `answer.tool_calls`,
`answer.usage`, `answer.stop_reason` — and `answer.raw` is still exactly what the
provider sent, for the days you need a vendor detail this shape does not model.

**Your own tools come back to you, and the reply says so.** A tool you declare in your own
request is one only your application can run, so the turn ends and hands the call back
instead of answering it: `call.executor` is `gridline.CALLER` for those, and
`gridline.HARNESS` for a call the platform runs, which you display and do nothing about.
One reply can carry both.

Declare them with `caller_tools` — not `tools`, which names the *connections* this turn
may reach — and send the result as the next turn, both in your own provider's vocabulary.
This library translates no request body, so the schema you write and the block you send
back are the ones your models already take:

```python
card = {"name": "render_invoice_card", "input_schema": {...}}

answer = chat.send("show me INV-7781", caller_tools=[card])
for call in answer.tool_calls:
    if call.executor == gridline.CALLER:
        answer = chat.send(messages=[{"role": "user", "content": [
            {"type": "tool_result", "tool_use_id": call.id, "content": draw(call)},
        ]}], caller_tools=[card])
```

That works while the proxy is keeping the conversation for you, too: a request carrying a
result is a request answering the call, so the turn it answers is still in the history you
get back.

**Degradation is readable rather than silent.** `answer.degraded` is true when the turn
got less than it was configured for; `route_degraded`, `session_degraded` and
`tools_degraded` say which, because a fallback model and a lost memory are not the same
problem. `answer.context_compacted` sits beside them rather than inside: a shortened
conversation is the harness working as configured, not a shortfall, but the model still
answered from less than you sent and nothing else in the reply says so.

**Retries are narrow on purpose.** A turn is not idempotent, so the client only repeats
what it knows did not run — a connection that was never established, a 429, a 503. A
read timeout is never retried, because the request arrived and something is working on
it. Pass `retry=gridline.Retry(attempts=5)` to widen it, or `gridline.NO_RETRY` to turn
it off.

**Approvals.** A tool configured to `ask` pauses the turn instead of running:

```python
answer = chat.send("post the January invoices")
if answer.awaiting_approval:
    for request in answer.approvals:
        print(request.name, request.arguments)      # a decision, not a button
    answer = chat.resume({r.id: True for r in answer.approvals})
```

The id is the *call's*, not the tool's: approving this `post_invoice` with these
arguments is something a person can be accountable for.

**Streaming.**

```python
with chat.stream("summarise the January ledger") as answer:
    for piece in answer:
        print(piece, end="", flush=True)
    print(answer.reply.usage.output_tokens)
```

Same arguments as `send`. Switching between them is adding or deleting a `for` loop.

---

## Which one

Take route 1 if you have an application already and want routing, failover and cost
attribution underneath it today. Take route 2 if a tool needs approving before it runs,
if you want one parsing path across providers, or if a degraded turn has to reach a
person. Nothing you configure differs between them, and running both against the same
agent is normal.
