conifer-sdk 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,580 @@
1
+ Metadata-Version: 2.4
2
+ Name: conifer-sdk
3
+ Version: 0.1.0
4
+ Summary: The Conifer gateway SDK: one client, exact per-turn cost receipts, and honest migration shims from OpenRouter, Vercel AI Gateway, and Helicone.
5
+ Author: Conifer
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://conifer.build
8
+ Project-URL: Documentation, https://conifer.build/docs/sdk/conifer/
9
+ Project-URL: Source, https://github.com/ConiferKit/use-conifer
10
+ Project-URL: Issues, https://github.com/ConiferKit/use-conifer/issues
11
+ Keywords: conifer,llm,openai,anthropic,gateway,ai,sdk
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0; extra == "dev"
20
+ Provides-Extra: tls
21
+ Requires-Dist: certifi; extra == "tls"
22
+
23
+ <div align="center">
24
+
25
+ <a href="https://conifer.build">
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="https://conifer.build/conifer-mark-spin-dark.png">
28
+ <img alt="Conifer" src="https://conifer.build/conifer-mark-spin-light.png" width="132">
29
+ </picture>
30
+ </a>
31
+
32
+ # The Conifer SDK
33
+
34
+ **One API key in front of every major model — and the exact cost of every call.**
35
+
36
+ [![CI](https://github.com/ConiferKit/use-conifer/actions/workflows/ci.yml/badge.svg)](https://github.com/ConiferKit/use-conifer/actions/workflows/ci.yml)
37
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
38
+ [![Docs](https://img.shields.io/badge/docs-conifer.build-1f6f4a.svg)](https://conifer.build/docs/sdk/)
39
+
40
+ [Docs](https://conifer.build/docs/sdk/) ·
41
+ [Migrating](https://conifer.build/docs/sdk/migrate/) ·
42
+ [Issues](https://github.com/ConiferKit/use-conifer/issues) ·
43
+ [Contributing](CONTRIBUTING.md)
44
+
45
+ </div>
46
+
47
+ One client for the [Conifer](https://conifer.build) gateway, in TypeScript and
48
+ Python, plus an MCP server so tools that speak no OpenAI wire can still call it.
49
+
50
+ Conifer speaks the OpenAI and Anthropic wires, so the base URL and the key are
51
+ most of a migration. Credits are charged at the model's list price, and every
52
+ call returns its exact settled cost — down to the nanodollar, itemized. Bring
53
+ your own provider keys and Conifer proxies them for a small fee on list price.
54
+
55
+ ## See it run
56
+
57
+ The router reads the question and picks the model. A question about a port
58
+ number went to Kimi K3 and came back in three seconds; a question about KV cache
59
+ limits, with the cost dial moved to *best*, went to Claude Opus 5 and took a
60
+ minute. Same session, nothing restarted, no frame sped up.
61
+
62
+ <div align="center">
63
+ <a href="https://conifer.build/#router">
64
+ <img alt="Claude Code running through Conifer's router. The router panel shows the turn routed to kimi-k3 with the cost dial on cheap; the answer, 5432, came back in 3 seconds. Click to watch the full recording on conifer.build." src="https://raw.githubusercontent.com/ConiferKit/use-conifer/main/docs/media/router-demo.jpg" width="760">
65
+ </a>
66
+ <br>
67
+ <sub><b><a href="https://conifer.build/#router">▶ Watch the router choose (50s, no audio)</a></b> — a real screen recording, playing on <a href="https://conifer.build">conifer.build</a></sub>
68
+ </div>
69
+
70
+ ```bash
71
+ export CONIFER_API_KEY='sk-conifer-…' # mint one at https://conifer.build/console#/keys
72
+ ```
73
+
74
+ > ```bash
75
+ > npm i conifer-sdk # TypeScript — live on npm
76
+ > pip install "conifer-sdk[tls]" # Python — keep the [tls] extra
77
+ > ```
78
+ >
79
+ > The Python package is not on PyPI yet; until it is, install it from this
80
+ > repository with `pip install "./use-conifer/python[tls]"` after
81
+ > `git clone https://github.com/ConiferKit/use-conifer`.
82
+ >
83
+ > On macOS, `[tls]` is what keeps a fresh python.org venv from failing its first
84
+ > call with `CERTIFICATE_VERIFY_FAILED`. [Why it is an extra rather than a
85
+ > dependency](#python-and-tls).
86
+
87
+ ```ts
88
+ import { Conifer, textOf } from "conifer-sdk";
89
+
90
+ const conifer = new Conifer();
91
+ const answer = await conifer.chat({
92
+ model: "claude-haiku-4-5",
93
+ messages: [{ role: "user", content: "three names for a build cache" }],
94
+ maxTokens: 200,
95
+ maxCostNanoUsd: 5_000_000, // refuse this turn if it could cost over $0.005
96
+ });
97
+
98
+ console.log(textOf(answer));
99
+ console.log(answer.receipt.costUsd); // "0.001250000" — this exact call
100
+ console.log(answer.receipt.costComponentsNanoUsd); // itemized across four token classes
101
+ ```
102
+
103
+ ```python
104
+ from conifer_sdk import Conifer, ChatRequest
105
+
106
+ conifer = Conifer()
107
+ answer = conifer.chat(ChatRequest(
108
+ model="claude-haiku-4-5",
109
+ messages=[{"role": "user", "content": "three names for a build cache"}],
110
+ max_tokens=200,
111
+ max_cost_nano_usd=5_000_000,
112
+ ))
113
+ print(answer.text, answer.receipt.cost_usd)
114
+
115
+ # Streaming, with the same semantics as the TypeScript twin.
116
+ for chunk in conifer.stream(ChatRequest(model="claude-haiku-4-5", messages=[...])):
117
+ ...
118
+ print(conifer.stream_receipt.effective_model) # routing arrives with the head
119
+ ```
120
+
121
+ The cost is on the **body too**, not only the headers:
122
+
123
+ ```ts
124
+ answer.usage.cost; // 0.00178 — decimal USD, the field OpenRouter uses
125
+ answer.usage.cost_nanousd; // 1780000 — the exact integer the gateway billed
126
+ answer.receipt.costNanoUsd; // the same number; the receipt stays the authority
127
+ ```
128
+
129
+ That duplication is deliberate. Conifer discloses cost on a response *header*,
130
+ and OpenRouter puts it in `usage.cost` — so every logging pipeline, request
131
+ recorder, LangChain/LiteLLM callback and JSON dump keeps the body and throws the
132
+ headers away. A team migrating would lose their cost column and never see why.
133
+ It matters more here than elsewhere: a normal caller cannot read their usage
134
+ history back, so the receipt on the turn is their only record of what they
135
+ spent.
136
+
137
+ It is additive only — a `cost` the gateway sends itself always wins — and it is
138
+ **absent** where the cost is unknown, because a `0` would read as "free".
139
+
140
+ On a **streamed** turn the cost headers are absent in both languages, and that
141
+ is the wire being honest rather than a gap: the response head is sent before the
142
+ first token and the money settles after the last. Reconcile a stream from its
143
+ terminal `usage` chunk, which the SDK always requests.
144
+
145
+ ### When the answer comes back empty
146
+
147
+ The most confusing thing this API can return is `""`, and the reason is never in
148
+ the content. A reasoning model spends `maxTokens` on its **thinking block
149
+ first** — so a budget that looks generous for a one-word answer can be consumed
150
+ entirely before the visible answer starts. You get empty content,
151
+ `finish_reason: "length"`, and a bill for every one of those output tokens.
152
+ Measured on both the OpenAI and Anthropic wires: `claude-fable-5` at
153
+ `maxTokens: 16` does exactly this; at 200 the same prompt answers fine.
154
+
155
+ That empty string looks identical to a refusal, a content filter, or a broken
156
+ SDK. So the SDK reads the one distinguishing field for you:
157
+
158
+ ```ts
159
+ const answer = await conifer.chat({ model, messages, maxTokens: 16 });
160
+ textOf(answer); // ""
161
+ emptyReason(answer); // "the model hit maxTokens before emitting visible text.
162
+ // On a reasoning model the thinking block is spent FIRST…"
163
+ ```
164
+
165
+ ```python
166
+ answer.text # ""
167
+ answer.empty_reason # the same sentence, or None when there is nothing to explain
168
+ ```
169
+
170
+ It returns `undefined`/`None` whenever there is text — and also for a tool call,
171
+ because empty text beside a tool call is the correct answer, not an absence.
172
+
173
+ ## Embeddings
174
+
175
+ Same key, same receipts, same cost ceiling — and the vectors arrive as plain
176
+ numbers whatever the wire did.
177
+
178
+ ```ts
179
+ const result = await conifer.embeddings.create({
180
+ model: "text-embedding-3-small",
181
+ input: ["alpha", "beta"], // one vector per input, in order
182
+ });
183
+
184
+ console.log(result.data[0].embedding.length); // 1536
185
+ console.log(result.receipt.costUsd); // "0.000000040" — settled, in band
186
+ ```
187
+
188
+ ```python
189
+ from conifer_sdk import EmbeddingsRequest, vector_of
190
+
191
+ result = conifer.embed(EmbeddingsRequest(
192
+ model="text-embedding-3-small",
193
+ input="hello world",
194
+ ))
195
+ print(len(vector_of(result)), result.receipt.cost_nano_usd)
196
+ ```
197
+
198
+ Three things worth knowing, because they are decisions rather than defaults:
199
+
200
+ - **base64 on the wire, numbers in your hands.** The SDK requests
201
+ `encoding_format: "base64"` and decodes it for you. A JSON float array spends
202
+ ~20 bytes per dimension against base64 float32's 5.33, so this is roughly 3x
203
+ less network on the one payload that is actually large. It is applied silently
204
+ only because it is exactly lossless — verified live, `text-embedding-3-small`
205
+ returns identical values both ways, max absolute difference 0.0. Pass
206
+ `encodingFormat: "float"` for JSON floats; `raw` always holds the provider's
207
+ own body either way.
208
+ - **Embeddings bill on input only.** There is no completion, so there is no
209
+ output term, no `max_tokens`, no sampling knobs and no stream. Unlike a
210
+ streamed chat turn, the cost is on this very response.
211
+ - **Refusals are legible.** A chat model sent here is a 400 naming the chat
212
+ door, not an opaque upstream 404 charged to you; token-id input is refused
213
+ client-side before any spend, because the gateway cannot price token ids it
214
+ did not tokenize.
215
+ - **Some models are not deterministic, and that is upstream of us.** Measured
216
+ 2026-08-27: six identical `bge-m3` calls returned four distinct vectors,
217
+ differing by up to 2.2e-4, while `text-embedding-3-small` returned the same
218
+ bytes every time. Batched GPU inference reorders float accumulation depending
219
+ on what else shares the batch. It is far below anything that changes a
220
+ ranking, but if you are diffing stored vectors or asserting on exact values in
221
+ a test, compare with a tolerance rather than `==`.
222
+
223
+ `conifer.cheapestFor(["embeddings"])` picks the cheapest embedding seat the
224
+ catalog actually declares, and each catalog row carries `embeddingDimensions`
225
+ (`embedding_dimensions` in Python) so you can size a `vector(1536)` column
226
+ before spending a token — getting that wrong means a migration on a populated
227
+ table.
228
+
229
+ ## Deferred jobs
230
+
231
+ For work that is not interactive — an overnight re-index, a bulk
232
+ classification, an eval sweep — submit the turn as a job and collect it later.
233
+
234
+ ```ts
235
+ const job = await conifer.defer({
236
+ model: "claude-fable-5",
237
+ messages: [{ role: "user", content: "classify these 400 tickets…" }],
238
+ });
239
+ console.log(job.jobId, job.status); // "job-gw-…", "queued"
240
+
241
+ const answer = await conifer.jobs.wait(job.jobId);
242
+ console.log(textOf(answer), answer.receipt.costUsd);
243
+ ```
244
+
245
+ ```python
246
+ job = conifer.defer(ChatRequest(model="claude-fable-5", messages=[...]))
247
+ answer = conifer.jobs_wait(job.job_id) # or job_status / job_result
248
+ ```
249
+
250
+ - **`chat({ defer: true })` throws, on purpose.** A deferred turn is answered
251
+ with 202 and a job envelope, not a completion — so `chat()` has nothing to
252
+ return. The previous behavior was worse than an error: the turn was accepted
253
+ *and debited*, and came back as `choices: []`, indistinguishable at the call
254
+ site from a model that answered with nothing.
255
+ - **The window floor is the gateway's, not ours.** Deferred work rides a
256
+ provider batch, so the gateway requires a completion window of at least 24h
257
+ and refuses anything narrower rather than quietly serving it synchronously at
258
+ a different price. `defer()` defaults to that floor so the common call works.
259
+ - **`wait()` stops on terminal states.** `cancelled`, `failed` and `expired`
260
+ never change; a poll loop keyed only on "is it ended yet" spins until the
261
+ process dies. It also backs off exponentially, and on timeout it raises
262
+ *without cancelling* — killing work you already paid for because a
263
+ client-side clock ran out is not a decision an SDK should make for you.
264
+
265
+ ## Keep your client. Get the receipts anyway.
266
+
267
+ The exact per-turn cost is the thing Conifer has that other gateways do not, and
268
+ it arrives on the **response headers** — which `openai`, `@anthropic-ai/sdk`,
269
+ LangChain, LiteLLM and the Vercel AI SDK all throw away. So pointing an existing
270
+ client at Conifer works perfectly and makes the whole differentiator invisible.
271
+
272
+ You do not have to rewrite anything to fix that. Every one of those clients takes
273
+ an injected `fetch` (or an `http_client`), so hand it one that reads the receipt
274
+ on the way past:
275
+
276
+ ```ts
277
+ import OpenAI from "openai";
278
+ import { ReceiptCollector } from "conifer-sdk";
279
+
280
+ const receipts = new ReceiptCollector();
281
+ const openai = new OpenAI({
282
+ baseURL: "https://api.conifer.build/v1",
283
+ apiKey: process.env.CONIFER_API_KEY,
284
+ fetch: receipts.fetch, // the only line that changes
285
+ });
286
+
287
+ await openai.chat.completions.create({ model: "claude-fable-5", messages });
288
+
289
+ receipts.last.costNanoUsd; // 580000 — that exact call
290
+ receipts.total.costUsd; // "0.001170000" — the whole session
291
+ ```
292
+
293
+ ```python
294
+ import httpx
295
+ from openai import OpenAI
296
+ from conifer_sdk import ReceiptCollector
297
+
298
+ receipts = ReceiptCollector()
299
+ openai = OpenAI(
300
+ base_url="https://api.conifer.build/v1",
301
+ api_key=os.environ["CONIFER_API_KEY"],
302
+ http_client=httpx.Client(event_hooks={"response": [receipts.httpx_hook]}),
303
+ )
304
+ ```
305
+
306
+ It never reads the response **body**. A body is a single-use stream that belongs
307
+ to the caller: consuming it to find a cost would break streaming and double
308
+ memory for everyone, and it would fail far from where it was caused. Headers are
309
+ already materialized, so observing them costs nothing and changes nothing —
310
+ the same response object is handed straight back.
311
+
312
+ `SpendBudget` answers the other question, the one no single request can:
313
+
314
+ ```ts
315
+ const budget = new SpendBudget(5_000_000_000); // $5 for this whole job
316
+ const openai = new OpenAI({ /* … */ fetch: budget.fetch });
317
+ ```
318
+
319
+ It refuses the *next* call once the budget is gone. It cannot refund the one that
320
+ crossed the line, because a turn's cost is only known after it settles — so the
321
+ true worst case is `budget + one turn`. Pair it with a per-request
322
+ `maxCostNanoUsd` and that overshoot is bounded rather than open-ended.
323
+
324
+ ### This works on all three wires
325
+
326
+ The gateway serves three request shapes, and the receipt headers are identical
327
+ on every one. Verified against the real vendor SDKs, unmodified:
328
+
329
+ | wire | client | verified |
330
+ | --- | --- | --- |
331
+ | `POST /v1/chat/completions` | `openai` → `.chat.completions` | ✅ receipts, streaming |
332
+ | `POST /v1/responses` | `openai` → `.responses` (the only wire Codex ≥ 0.145 speaks) | ✅ receipts |
333
+ | `POST /v1/messages` | `anthropic` → `.messages` | ✅ receipts, streaming |
334
+
335
+ ```python
336
+ import anthropic
337
+ client = anthropic.Anthropic(
338
+ base_url="https://api.conifer.build", # note: no /v1 on the Anthropic door
339
+ api_key=os.environ["CONIFER_API_KEY"],
340
+ http_client=httpx.Client(event_hooks={"response": [receipts.httpx_hook]}),
341
+ )
342
+ ```
343
+
344
+ This SDK deliberately does **not** reimplement the Responses or Messages wires.
345
+ Your vendor SDK already speaks them correctly, the gateway relays them
346
+ faithfully, and a third implementation of someone else's wire is a liability,
347
+ not a feature. `ReceiptCollector` is the piece that was missing, and it is
348
+ wire-agnostic because it reads headers.
349
+
350
+ ## Why this exists when the OpenAI SDK already works
351
+
352
+ It still does, and it remains the right choice for a plain drop-in. This package
353
+ exists for the four things the OpenAI client structurally cannot give you:
354
+
355
+ | | |
356
+ | --- | --- |
357
+ | **The receipt** | Every response carries the exact integer nanodollar cost of *that call*, itemized across fresh input, cache write, cache read, and output. No second stats request, no float dollars, no estimating from token counts and a price table. |
358
+ | **Named refusals** | A 402 is *three* different problems: the account is out of credit, your own per-request ceiling refused this turn, or this key's lifetime cap is spent. The remedies are unrelated — top up, raise the ceiling, or rotate the key — so they are `ConiferPaymentError`, `ConiferCostCeilingError` and `ConiferKeySpendCapError`, not one status number. A 409 splits the same way: two of them mean "retry shortly" and are retried for you; the third never will be. |
359
+ | **The spend ceiling** | `maxCostNanoUsd` is a hard, server-enforced bound checked *before* any upstream call. The gateway refuses rather than serves. |
360
+ | **Portability** | Migration shims that refuse what Conifer cannot honor instead of dropping it silently. |
361
+
362
+ ## Migrating from another gateway
363
+
364
+ Conifer speaks the OpenAI wire, so the base URL and key are most of the work:
365
+
366
+ ```ts
367
+ // Vercel AI Gateway -> Conifer
368
+ - baseURL: "https://ai-gateway.vercel.sh/v1", apiKey: process.env.AI_GATEWAY_API_KEY
369
+ + baseURL: "https://api.conifer.build/v1", apiKey: process.env.CONIFER_API_KEY
370
+
371
+ // OpenRouter -> Conifer (vendor/model ids resolve unchanged)
372
+ - baseURL: "https://openrouter.ai/api/v1", apiKey: process.env.OPENROUTER_API_KEY
373
+ + baseURL: "https://api.conifer.build/v1", apiKey: process.env.CONIFER_API_KEY
374
+ ```
375
+
376
+ The rest is the part that usually goes wrong quietly. **The shims refuse what
377
+ Conifer cannot honor, and name the replacement:**
378
+
379
+ ```ts
380
+ import { fromOpenRouter } from "conifer-sdk";
381
+
382
+ fromOpenRouter({ model: "anthropic/claude-opus-5", messages, provider: { order: ["anthropic"] } });
383
+ // ConiferPortabilityError: OpenRouter's `provider` preferences pin a serving host.
384
+ // Conifer picks the host for the admitted model itself, by price and health, and no
385
+ // client can override it. Remove the block, or use `maxCostNanoUsd` if the goal was
386
+ // cost control.
387
+ ```
388
+
389
+ That is deliberate. Dropping a provider pin, a moderation flag, or a rate-limit
390
+ policy on the floor is what makes a migration *look* clean while changing what
391
+ runs and what it costs. The full honored/translated/refused matrix, field by
392
+ field, is [`cards/portability.card.json`](cards/portability.card.json).
393
+
394
+ The one thing worth knowing up front: **Conifer admits exactly the model you
395
+ name.** There is no server-side fallback list. OpenRouter's `models`, Vercel's
396
+ `gateway.models`, and `Helicone-Fallbacks` all become a *client-side* chain of
397
+ separate billed requests, which you must opt into:
398
+
399
+ ```ts
400
+ const answer = await conifer.chat({
401
+ model: "claude-opus-5",
402
+ messages,
403
+ fallbackModels: ["claude-haiku-4-5"],
404
+ allowClientFallback: true, // yes, I accept these are separate billed calls
405
+ });
406
+ answer.fallbackIndex; // 0 = the model you asked for, 1 = the first fallback
407
+ ```
408
+
409
+ Only a *retryable* failure advances the chain. A 402 or a bad request is the
410
+ same answer on every member, and spending on a second model would not fix it.
411
+
412
+ ## The MCP server
413
+
414
+ The paste-one-line-into-your-agent trick only helps a tool that already speaks
415
+ the OpenAI wire. An agent, a Slack bot, or an IDE that speaks MCP has no such
416
+ hook — it can only use what its host exposes as a tool. So:
417
+
418
+ Build it once, then point any MCP host at the compiled binary:
419
+
420
+ ```bash
421
+ git clone https://github.com/ConiferKit/use-conifer
422
+ cd use-conifer && npm install && npm run build
423
+ ```
424
+
425
+ ```json
426
+ {
427
+ "mcpServers": {
428
+ "conifer": {
429
+ "command": "node",
430
+ "args": ["/path/to/use-conifer/bin/conifer-mcp.mjs"],
431
+ "env": { "CONIFER_API_KEY": "sk-conifer-…" }
432
+ }
433
+ }
434
+ }
435
+ ```
436
+
437
+ The npx form is now the recommended config:
438
+ `"command": "npx", "args": ["-y", "conifer-sdk", "conifer-mcp"]`, which removes
439
+ the build step. The path form above still works for local development against
440
+ an unpublished checkout.
441
+
442
+ Six tools, each one real gateway call:
443
+
444
+ - `conifer_complete` — ask any model a question, or hand it a whole conversation. The answer returns **with what it cost**, and `max_cost_nanousd` bounds the spend before the call. An empty answer carries `empty_reason`, so an agent is told *why* instead of retrying and paying twice.
445
+ - `conifer_compare` — the same prompt across 2–5 models in parallel, each answer beside its cost, cheapest first. The ceiling caps each turn, not the total.
446
+ - `conifer_embed` — text to embedding vectors, with the settled cost. Returns the shape, the cost and a short preview rather than the raw vectors: a single 1536-dimension embedding is ~30 KB of digits that no model can read, and a batch would swallow the context window.
447
+ - `conifer_list_models` — the catalog, with declared capabilities and as-charged prices.
448
+ - `conifer_choose_model` — the cheapest model *declaring* the capabilities you need. It skips models with undeclared capabilities rather than assuming them, and unpriced models rather than assuming they are free.
449
+ - `conifer_balance` — remaining credit.
450
+
451
+ The reason `conifer_complete` reports its cost is that an agent that can see
452
+ what its last call cost can be told to spend less. One that cannot, cannot.
453
+
454
+ ### A Slack bot that routes by cost
455
+
456
+ ```ts
457
+ import { Conifer } from "conifer-sdk";
458
+
459
+ const conifer = new Conifer({ defaultHeaders: { "x-conifer-client": "slack-bot" } });
460
+
461
+ export async function onMention(text: string, isLongTask: boolean) {
462
+ // Pick from what the catalog actually declares, not from a hardcoded list.
463
+ const model = await conifer.cheapestFor(isLongTask ? ["tools"] : [], {
464
+ minContextWindow: isLongTask ? 200_000 : undefined,
465
+ });
466
+ if (model === undefined) return "no model in the catalog fits that request";
467
+
468
+ const answer = await conifer.chat({
469
+ model: model.id,
470
+ messages: [{ role: "user", content: text }],
471
+ maxTokens: 800,
472
+ maxCostNanoUsd: 20_000_000, // $0.02 per Slack reply, hard ceiling
473
+ deadlineSeconds: isLongTask ? 900 : undefined, // advisory: may serve on a cheaper tier
474
+ });
475
+
476
+ return `${answer.choices[0]?.message?.content}\n\n_${model.id} · $${answer.receipt.costUsd}_`;
477
+ }
478
+ ```
479
+
480
+ ## The cards
481
+
482
+ This package's contract is data rather than prose, so it cannot drift from the code that reads it:
483
+
484
+ - [`cards/sdk.input.card.json`](cards/sdk.input.card.json) — everything the SDK reads, and which gateway input each field maps to.
485
+ - [`cards/sdk.output.card.json`](cards/sdk.output.card.json) — everything it emits, including every receipt field and every error class.
486
+ - [`cards/portability.card.json`](cards/portability.card.json) — the migration contract, per competitor.
487
+
488
+ The cards are *tested*, not decorative: `tests/cards.test.ts` reads the
489
+ gateway's own generated wire contract — vendored at
490
+ [`contracts/gateway-contract.json`](contracts/gateway-contract.json) and pinned
491
+ by byte — and fails if a receipt header the gateway emits is not parsed, if a header the input card claims is
492
+ never sent, or if a field the portability card calls unsupported does not
493
+ actually refuse. The Python suite re-checks the same portability card, so both
494
+ languages refuse the same things.
495
+
496
+ ## TypeScript consumers
497
+
498
+ Target **ES2018 or later** (`"target": "ES2018"`, or `"lib": ["ES2018"]`). The
499
+ stream type is an `AsyncIterable`, whose name only exists in `lib.es2018`, so an
500
+ older target reports `TS2583` pointing into our declarations. The official
501
+ `openai` package has the same requirement for the same reason — async iteration
502
+ cannot be described without the names that describe it.
503
+
504
+ Verified from a real `npm i`: an ES2022 consumer typechecks clean under
505
+ `strict` with **no** `skipLibCheck`, and CommonJS `require()` works on Node 22,
506
+ 24 and 26.
507
+
508
+ ## Python and TLS
509
+
510
+ The Python package has **zero dependencies**, which is a real feature: it drops
511
+ into a lambda or a locked-down build image with no package tree to audit. So
512
+ `certifi` ships as the optional `[tls]` extra rather than a hard dependency.
513
+
514
+ You want that extra on macOS. A python.org install whose *Install
515
+ Certificates.command* was never run has an empty CA trust store, and so does
516
+ every venv built on it — it cannot verify any HTTPS host, and the first call
517
+ dies with `CERTIFICATE_VERIFY_FAILED`. With `[tls]` installed the SDK detects
518
+ the empty store and uses `certifi` automatically. Linux, Homebrew, Docker and
519
+ conda already have a working store.
520
+
521
+ Hit it without the extra and the error says so, and names the fix, rather than
522
+ reporting that the gateway is unreachable.
523
+
524
+ ## Tests
525
+
526
+ ```bash
527
+ npm run build # emit dist/ (ESM + .d.ts)
528
+ npm test # 162 tests, offline
529
+ npm run typecheck
530
+
531
+ cd python && python3 -m pytest -q # 105 tests, offline
532
+ ```
533
+
534
+ Most assertions run with an injected transport: no network, no mock framework,
535
+ and every one is about bytes that would go on the wire or values handed back.
536
+ `tests/packaging.test.ts` is the exception, and it matters: it checks the
537
+ package as a consumer receives it, which is where two real defects hid.
538
+
539
+ ### Verified against the live gateway
540
+
541
+ A suite that mocks the server can only confirm what we already believed, so
542
+ every claim in this README is also checked against production:
543
+
544
+ ```bash
545
+ CONIFER_API_KEY=sk-… npm run qa:live # 20 checks
546
+ CONIFER_API_KEY=sk-… node scripts/live-qa.mjs --include-deferred # 22
547
+
548
+ cd python && CONIFER_API_KEY=sk-… python3 scripts/live_qa.py --include-deferred
549
+ ```
550
+
551
+ It exercises every surface — catalog, chat, streaming, embeddings, receipts,
552
+ budgets, deferred jobs, and each refusal — against `api.conifer.build`, in both
553
+ languages, and prints the real cost of what it just did. A fresh-install pass
554
+ installs the packed tarball and the Python package into clean projects and uses
555
+ them as a consumer does.
556
+
557
+ It **spends real money** (a few tenths of a cent), which is why it is not part
558
+ of `npm test`: run it before a release, deliberately.
559
+
560
+ This gate earns its keep. Every defect found in the 2026-08-27 pass was
561
+ invisible offline and obvious here — three error classes unreachable in
562
+ production, a caller's `requestId` never once consulted, and a deferred turn
563
+ that was billed and returned nothing readable.
564
+
565
+ ## What Conifer does not do
566
+
567
+ Stated here so you find out now rather than mid-migration:
568
+
569
+ - **No image generation, reranking, moderation, audio, Files, or Batches.**
570
+ `assertSupportedVercelSurface` throws at the call site, naming the remedy,
571
+ rather than letting you find out as a 404 in production on the one code path
572
+ nobody exercised.
573
+ - **No provider pinning.** The gateway chooses the host for the model you named, by price and health. The model is never substituted.
574
+ - **No server-side prompt compression, moderation, injection scanning, or prompt registry.**
575
+ - **No mid-stream fallback.** The first token commits the turn, so a chain cannot be attached to a stream.
576
+
577
+ ## License
578
+
579
+ [Apache License 2.0](LICENSE). Contributions are welcome under the same license
580
+ — start with [CONTRIBUTING.md](CONTRIBUTING.md).