@lobstack-ai/mcp 0.1.0

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,84 @@
1
+ /**
2
+ * The receipt, and the two rules about rendering one.
3
+ *
4
+ * These rules are not local style. They are the same rules as
5
+ * `cli/src/render.mjs` in the platform repo, and the reason there is one copy
6
+ * per client rather than one copy per renderer is that a rule about overstating
7
+ * savings drifts the moment it is restated.
8
+ */
9
+ /** `baseline_reason` as the Gateway sends it. See `src/lib/gateway/stream.ts`. */
10
+ export type BaselineReason = "named" | "plan_ceiling";
11
+ export interface Receipt {
12
+ request_id: string | null;
13
+ served_model: string | null;
14
+ requested_model: string | null;
15
+ routed: boolean | null;
16
+ /** Null, never 0, for a call the Gateway could not price. */
17
+ cost_usd: number | null;
18
+ savings_usd: number | null;
19
+ priced: boolean;
20
+ baseline_model: string | null;
21
+ /** Missing is treated as unnamed. Never as the flattering case. */
22
+ baseline_reason: BaselineReason | null;
23
+ baseline_cost_usd: number | null;
24
+ }
25
+ /**
26
+ * A number, or null. Never a zero standing in for "we do not know".
27
+ *
28
+ * `cost_usd: null` means the Gateway could not price the call. A real zero —
29
+ * a request that produced no billable tokens — is a different fact and is kept
30
+ * as a zero. Collapsing the two is the bug this whole module exists to prevent.
31
+ */
32
+ export declare function asMoney(v: unknown): number | null;
33
+ /**
34
+ * Format money for a human.
35
+ *
36
+ * "unpriced" for null, never "$0.00". That exact substitution ran for three
37
+ * months in production and wrote off real charges as free.
38
+ */
39
+ export declare function money(n: number | null | undefined): string;
40
+ /** Coerce whatever came off the trailing frame into a receipt, or null. */
41
+ export declare function parseReceipt(raw: unknown): Receipt | null;
42
+ export interface Savings {
43
+ label: string;
44
+ named: boolean;
45
+ amount: number;
46
+ baseline_model: string | null;
47
+ baseline_reason: BaselineReason | null;
48
+ }
49
+ /**
50
+ * Whether a saving may be called a saving, in one place.
51
+ *
52
+ * `baseline_reason` decides.
53
+ *
54
+ * "named" the caller asked for a model and got something cheaper. A
55
+ * like-for-like comparison, and the only case that may be
56
+ * labelled `saved`.
57
+ * "plan_ceiling" the caller sent `auto`, so the Gateway measured against the
58
+ * most expensive model their plan allows. Real, and not
59
+ * something anybody asked for: `vs ceiling`.
60
+ * missing treated as unnamed. A receipt that does not say where its
61
+ * baseline came from does not get the flattering reading.
62
+ *
63
+ * Matches `savingsLabel()` in `cli/src/render.mjs` exactly.
64
+ */
65
+ export declare function savingsLabel(receipt: Receipt | null): Savings | null;
66
+ export interface ReceiptLineInput {
67
+ receipt: Receipt | null;
68
+ usage: {
69
+ prompt_tokens: number;
70
+ completion_tokens: number;
71
+ } | null;
72
+ /** The model the stream frames named, when the receipt did not. */
73
+ model?: string | null;
74
+ /** Parameters the Gateway dropped, from `x-lobstack-dropped-params`. */
75
+ droppedParams?: string[];
76
+ }
77
+ /**
78
+ * The receipt as a human reads it: one line, then whatever needs saying.
79
+ *
80
+ * This is returned as its own content block, separate from the answer, for the
81
+ * same reason the CLI writes it to stderr — so the thing you asked for and the
82
+ * thing it cost do not end up concatenated in whatever consumes the answer.
83
+ */
84
+ export declare function describeReceipt({ receipt, usage, model, droppedParams }: ReceiptLineInput): string;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The receipt, and the two rules about rendering one.
3
+ *
4
+ * These rules are not local style. They are the same rules as
5
+ * `cli/src/render.mjs` in the platform repo, and the reason there is one copy
6
+ * per client rather than one copy per renderer is that a rule about overstating
7
+ * savings drifts the moment it is restated.
8
+ */
9
+ /**
10
+ * A number, or null. Never a zero standing in for "we do not know".
11
+ *
12
+ * `cost_usd: null` means the Gateway could not price the call. A real zero —
13
+ * a request that produced no billable tokens — is a different fact and is kept
14
+ * as a zero. Collapsing the two is the bug this whole module exists to prevent.
15
+ */
16
+ export function asMoney(v) {
17
+ if (typeof v === "number" && Number.isFinite(v))
18
+ return v;
19
+ // A JSON number that arrived as a string still prices; "" and null do not.
20
+ if (typeof v === "string" && v.trim() !== "") {
21
+ const n = Number(v);
22
+ if (Number.isFinite(n))
23
+ return n;
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * Format money for a human.
29
+ *
30
+ * "unpriced" for null, never "$0.00". That exact substitution ran for three
31
+ * months in production and wrote off real charges as free.
32
+ */
33
+ export function money(n) {
34
+ if (typeof n !== "number" || !Number.isFinite(n))
35
+ return "unpriced";
36
+ return n >= 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(6)}`;
37
+ }
38
+ /** Coerce whatever came off the trailing frame into a receipt, or null. */
39
+ export function parseReceipt(raw) {
40
+ if (!raw || typeof raw !== "object")
41
+ return null;
42
+ const r = raw;
43
+ const reason = r.baseline_reason;
44
+ return {
45
+ request_id: typeof r.request_id === "string" ? r.request_id : null,
46
+ served_model: typeof r.served_model === "string" ? r.served_model : null,
47
+ requested_model: typeof r.requested_model === "string" ? r.requested_model : null,
48
+ routed: typeof r.routed === "boolean" ? r.routed : null,
49
+ cost_usd: asMoney(r.cost_usd),
50
+ savings_usd: asMoney(r.savings_usd),
51
+ // `priced` is an assertion the seller makes. Absent, assume nothing: a
52
+ // receipt with no price is not a priced receipt.
53
+ priced: r.priced === true,
54
+ baseline_model: typeof r.baseline_model === "string" ? r.baseline_model : null,
55
+ baseline_reason: reason === "named" || reason === "plan_ceiling" ? reason : null,
56
+ baseline_cost_usd: asMoney(r.baseline_cost_usd),
57
+ };
58
+ }
59
+ /**
60
+ * Whether a saving may be called a saving, in one place.
61
+ *
62
+ * `baseline_reason` decides.
63
+ *
64
+ * "named" the caller asked for a model and got something cheaper. A
65
+ * like-for-like comparison, and the only case that may be
66
+ * labelled `saved`.
67
+ * "plan_ceiling" the caller sent `auto`, so the Gateway measured against the
68
+ * most expensive model their plan allows. Real, and not
69
+ * something anybody asked for: `vs ceiling`.
70
+ * missing treated as unnamed. A receipt that does not say where its
71
+ * baseline came from does not get the flattering reading.
72
+ *
73
+ * Matches `savingsLabel()` in `cli/src/render.mjs` exactly.
74
+ */
75
+ export function savingsLabel(receipt) {
76
+ const amount = receipt?.savings_usd;
77
+ if (typeof amount !== "number" || !(amount > 0))
78
+ return null;
79
+ const named = receipt?.baseline_reason === "named";
80
+ return {
81
+ label: named ? "saved" : "vs ceiling",
82
+ named,
83
+ amount,
84
+ baseline_model: receipt?.baseline_model ?? null,
85
+ baseline_reason: receipt?.baseline_reason ?? null,
86
+ };
87
+ }
88
+ /**
89
+ * The receipt as a human reads it: one line, then whatever needs saying.
90
+ *
91
+ * This is returned as its own content block, separate from the answer, for the
92
+ * same reason the CLI writes it to stderr — so the thing you asked for and the
93
+ * thing it cost do not end up concatenated in whatever consumes the answer.
94
+ */
95
+ export function describeReceipt({ receipt, usage, model, droppedParams }) {
96
+ const served = receipt?.served_model || model || "unknown";
97
+ const parts = [`model ${served}`];
98
+ if (receipt?.requested_model && receipt.routed)
99
+ parts.push(`asked ${receipt.requested_model}`);
100
+ if (usage)
101
+ parts.push(`tokens ${usage.prompt_tokens}/${usage.completion_tokens}`);
102
+ parts.push(`cost ${money(receipt?.cost_usd ?? null)}`);
103
+ const saving = savingsLabel(receipt);
104
+ if (saving)
105
+ parts.push(`${saving.label} ${money(saving.amount)}`);
106
+ const lines = [`— ${parts.join(" · ")}`];
107
+ if (saving && !saving.named && saving.baseline_reason === "plan_ceiling" && saving.baseline_model) {
108
+ lines.push(` measured against ${saving.baseline_model}, the priciest model your plan allows — you sent auto, not that model`);
109
+ }
110
+ if (receipt && !receipt.priced) {
111
+ lines.push(" the gateway could not price this model, so no cost is claimed");
112
+ }
113
+ if (!receipt) {
114
+ lines.push(" no receipt on this response — the endpoint did not send one");
115
+ }
116
+ if (droppedParams?.length) {
117
+ lines.push(` the served model does not accept ${droppedParams.join(", ")}; it was dropped`);
118
+ }
119
+ if (receipt?.request_id)
120
+ lines.push(` request ${receipt.request_id}`);
121
+ return lines.join("\n");
122
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The MCP server: four tools over the Lobstack Gateway.
3
+ *
4
+ * Exported as a factory rather than wired straight to stdio so the tests can
5
+ * drive it over an in-memory transport with a real MCP client on the other end,
6
+ * against a fake gateway speaking real HTTP. What breaks in a thing like this
7
+ * is at the seams — a frame split by the network, a receipt read off the wrong
8
+ * field, a redirect quietly eating the key — and none of that shows up when you
9
+ * call your own parser with a string you wrote.
10
+ *
11
+ * THE TOOL SET, AND WHY IT IS THIS ONE
12
+ *
13
+ * lobstack_route_preview scores a prompt and names the model that would
14
+ * serve it. No key. The only tool that works on a
15
+ * fresh install, which makes it the one that shows
16
+ * what the product does before anybody has signed up.
17
+ * lobstack_models the catalogue, with per-token prices and tiers.
18
+ * lobstack_chat the actual completion, and the receipt for it.
19
+ * lobstack_spend the ledger over a range.
20
+ *
21
+ * Nothing here mints, rotates or reads API keys, and nothing accepts a base URL
22
+ * as an argument. This process holds a live credential for as long as the MCP
23
+ * client runs; the two ways that credential gets away from you are a tool that
24
+ * can redirect it somewhere and a tool that can hand out another one. Neither
25
+ * exists here.
26
+ */
27
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
+ export declare const SERVER_NAME = "lobstack";
29
+ /**
30
+ * Read from package.json, not typed here.
31
+ *
32
+ * This is the version the server announces over MCP `initialize`, and it was a
33
+ * literal sitting beside a `version` field in the manifest — two places to bump
34
+ * and one to forget. `npm version` writes the manifest and nothing else, so a
35
+ * release would have shipped announcing the previous version to every client.
36
+ */
37
+ export declare const SERVER_VERSION: string;
38
+ export interface CreateServerOptions {
39
+ /** Overrides LOBSTACK_BASE_URL. Process configuration; never a tool argument. */
40
+ baseUrl?: string | null;
41
+ /** Overrides LOBSTACK_API_KEY. */
42
+ apiKey?: string | null;
43
+ }
44
+ export declare function createServer(options?: CreateServerOptions): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The MCP server: four tools over the Lobstack Gateway.
3
+ *
4
+ * Exported as a factory rather than wired straight to stdio so the tests can
5
+ * drive it over an in-memory transport with a real MCP client on the other end,
6
+ * against a fake gateway speaking real HTTP. What breaks in a thing like this
7
+ * is at the seams — a frame split by the network, a receipt read off the wrong
8
+ * field, a redirect quietly eating the key — and none of that shows up when you
9
+ * call your own parser with a string you wrote.
10
+ *
11
+ * THE TOOL SET, AND WHY IT IS THIS ONE
12
+ *
13
+ * lobstack_route_preview scores a prompt and names the model that would
14
+ * serve it. No key. The only tool that works on a
15
+ * fresh install, which makes it the one that shows
16
+ * what the product does before anybody has signed up.
17
+ * lobstack_models the catalogue, with per-token prices and tiers.
18
+ * lobstack_chat the actual completion, and the receipt for it.
19
+ * lobstack_spend the ledger over a range.
20
+ *
21
+ * Nothing here mints, rotates or reads API keys, and nothing accepts a base URL
22
+ * as an argument. This process holds a live credential for as long as the MCP
23
+ * client runs; the two ways that credential gets away from you are a tool that
24
+ * can redirect it somewhere and a tool that can hand out another one. Neither
25
+ * exists here.
26
+ */
27
+ import { readFileSync } from "node:fs";
28
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
29
+ import { loadConfig } from "./config.js";
30
+ import { chatInput, chatOutput, runChat } from "./tools/chat.js";
31
+ import { modelsInput, modelsOutput, runModels } from "./tools/models.js";
32
+ import { routePreviewInput, routePreviewOutput, runRoutePreview } from "./tools/route-preview.js";
33
+ import { spendInput, spendOutput, runSpend } from "./tools/spend.js";
34
+ export const SERVER_NAME = "lobstack";
35
+ /**
36
+ * Read from package.json, not typed here.
37
+ *
38
+ * This is the version the server announces over MCP `initialize`, and it was a
39
+ * literal sitting beside a `version` field in the manifest — two places to bump
40
+ * and one to forget. `npm version` writes the manifest and nothing else, so a
41
+ * release would have shipped announcing the previous version to every client.
42
+ */
43
+ export const SERVER_VERSION = (() => {
44
+ try {
45
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
46
+ }
47
+ catch {
48
+ return "0.0.0";
49
+ }
50
+ })();
51
+ const INSTRUCTIONS = `Lobstack is a metered LLM gateway: one key reaches every major model, and every call
52
+ comes back with a receipt saying which model served it and what it cost.
53
+
54
+ - lobstack_route_preview needs NO API key. It scores a prompt against the same
55
+ router the paid path uses and reports the model that would serve it and the
56
+ estimated cost. Use it to choose a model, or to show what routing does.
57
+ - lobstack_chat runs the completion. Send model "auto" to let the router pick
58
+ the cheapest model that can handle the prompt.
59
+ - Costs are reported as the gateway priced them. A null cost means the gateway
60
+ could not price the call — it does not mean the call was free.
61
+ - A saving labelled "saved" is like-for-like: the caller named a model and got
62
+ something cheaper. A saving labelled "vs ceiling" is measured against the most
63
+ expensive model the plan allows, which nobody asked for. Do not describe the
64
+ second as if it were the first.`;
65
+ export function createServer(options = {}) {
66
+ const cfg = loadConfig(options);
67
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
68
+ server.registerTool("lobstack_route_preview", {
69
+ title: "Preview routing and cost",
70
+ description: "Score a prompt and report which model the Lobstack router would serve it with, and what that would cost. " +
71
+ "Runs no inference, spends nothing, and NEEDS NO API KEY — use it to pick a model before calling lobstack_chat, " +
72
+ "or to show what the gateway does on a machine with no key configured. Token counts are estimates; the billed " +
73
+ "figure comes from the provider's usage block on the real call.",
74
+ inputSchema: routePreviewInput,
75
+ outputSchema: routePreviewOutput,
76
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
77
+ }, async (args) => runRoutePreview(cfg, args));
78
+ server.registerTool("lobstack_models", {
79
+ title: "List gateway models",
80
+ description: "The models the Lobstack Gateway serves, with capability tier, provider, context window and USD price per " +
81
+ "million input and output tokens. A model the registry cannot price shows a null price, not zero.",
82
+ inputSchema: modelsInput,
83
+ outputSchema: modelsOutput,
84
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
85
+ }, async (args) => runModels(cfg, args));
86
+ server.registerTool("lobstack_chat", {
87
+ title: "Chat through the gateway",
88
+ description: "Send a prompt or conversation through the Lobstack Gateway and get the reply plus a receipt: the model that " +
89
+ 'actually served it, token counts, USD cost, and any saving with the reason it may be claimed. Model "auto" ' +
90
+ "(the default) lets the router pick the cheapest model that can handle the prompt. This call spends money " +
91
+ "against the configured key's allowance.",
92
+ inputSchema: chatInput,
93
+ outputSchema: chatOutput,
94
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
95
+ }, async (args) => runChat(cfg, args));
96
+ server.registerTool("lobstack_spend", {
97
+ title: "Read spend and usage",
98
+ description: "What this organization has spent through the gateway over a range, broken down by day, model, key or agent, " +
99
+ "with request counts, tokens, error counts and latency percentiles. Requires an API key with the usage:read " +
100
+ "scope. Reports how many requests could not be priced, because a total that includes them is a floor.",
101
+ inputSchema: spendInput,
102
+ outputSchema: spendOutput,
103
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
104
+ }, async (args) => runSpend(cfg, args));
105
+ return server;
106
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Read an OpenAI-compatible SSE stream, and keep the receipt.
3
+ *
4
+ * Three things here are load-bearing, and all three are the same rule as
5
+ * `cli/src/stream.mjs` in the platform repo — deliberately, because two
6
+ * different readers for one wire format is two chances to disagree about what
7
+ * the seller said a call cost.
8
+ *
9
+ * The splitter buffers across chunk boundaries. A JSON frame can be cut in the
10
+ * middle by the network, and parsing per chunk instead of per frame drops
11
+ * tokens — which surfaces as answers that end mid-sentence and that nobody can
12
+ * reproduce.
13
+ *
14
+ * The trailing buffer is flushed at end of stream. A server that closes without
15
+ * a final blank line still owes us its last event, and the last event is the
16
+ * one carrying the price.
17
+ *
18
+ * `\r\n\r\n` is accepted alongside `\n\n`. Our gateway emits `\n\n`; a proxy in
19
+ * between is entitled to normalise line endings, and a reader that only knows
20
+ * one of the two spellings loses every frame when it does.
21
+ */
22
+ /** Yield each `data:` payload from an SSE body, whole. */
23
+ export declare function sseFrames(body: ReadableStream<Uint8Array>): AsyncGenerator<string>;
24
+ export interface StreamUsage {
25
+ prompt_tokens: number;
26
+ completion_tokens: number;
27
+ total_tokens: number;
28
+ }
29
+ export interface ConsumedStream {
30
+ text: string;
31
+ usage: StreamUsage | null;
32
+ /** `x_lobstack` off the trailing frame, verbatim. Null if it never arrived. */
33
+ receipt: unknown;
34
+ /** The model the frames named, which is the model that actually served. */
35
+ model: string | null;
36
+ finishReason: string | null;
37
+ }
38
+ export declare class StreamError extends Error {
39
+ constructor(message: string);
40
+ }
41
+ /**
42
+ * Consume a completion stream to the end.
43
+ *
44
+ * The whole answer is accumulated rather than forwarded: an MCP tool result is
45
+ * one message, so there is nowhere to stream it to. The request is still made
46
+ * with `stream: true` and `include_usage`, because the trailing SSE frame is the
47
+ * only place the Gateway reports a streamed call's price — headers are written
48
+ * before the provider has counted a token.
49
+ */
50
+ export declare function consume(body: ReadableStream<Uint8Array>, onText?: (delta: string) => void): Promise<ConsumedStream>;
package/dist/sse.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Read an OpenAI-compatible SSE stream, and keep the receipt.
3
+ *
4
+ * Three things here are load-bearing, and all three are the same rule as
5
+ * `cli/src/stream.mjs` in the platform repo — deliberately, because two
6
+ * different readers for one wire format is two chances to disagree about what
7
+ * the seller said a call cost.
8
+ *
9
+ * The splitter buffers across chunk boundaries. A JSON frame can be cut in the
10
+ * middle by the network, and parsing per chunk instead of per frame drops
11
+ * tokens — which surfaces as answers that end mid-sentence and that nobody can
12
+ * reproduce.
13
+ *
14
+ * The trailing buffer is flushed at end of stream. A server that closes without
15
+ * a final blank line still owes us its last event, and the last event is the
16
+ * one carrying the price.
17
+ *
18
+ * `\r\n\r\n` is accepted alongside `\n\n`. Our gateway emits `\n\n`; a proxy in
19
+ * between is entitled to normalise line endings, and a reader that only knows
20
+ * one of the two spellings loses every frame when it does.
21
+ */
22
+ /** Yield each `data:` payload from an SSE body, whole. */
23
+ export async function* sseFrames(body) {
24
+ const reader = body.getReader();
25
+ const decoder = new TextDecoder();
26
+ let buffer = "";
27
+ try {
28
+ for (;;) {
29
+ const { done, value } = await reader.read();
30
+ if (done)
31
+ break;
32
+ buffer += decoder.decode(value, { stream: true });
33
+ let idx;
34
+ while ((idx = buffer.search(/\r?\n\r?\n/)) !== -1) {
35
+ const rawEvent = buffer.slice(0, idx);
36
+ buffer = buffer.slice(idx + (buffer[idx] === "\r" ? 4 : 2));
37
+ for (const line of rawEvent.split(/\r?\n/)) {
38
+ if (!line.startsWith("data:"))
39
+ continue;
40
+ const payload = line.slice(5).trim();
41
+ if (payload)
42
+ yield payload;
43
+ }
44
+ }
45
+ }
46
+ for (const line of buffer.split(/\r?\n/)) {
47
+ if (!line.startsWith("data:"))
48
+ continue;
49
+ const payload = line.slice(5).trim();
50
+ if (payload)
51
+ yield payload;
52
+ }
53
+ }
54
+ finally {
55
+ reader.releaseLock();
56
+ }
57
+ }
58
+ export class StreamError extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = "StreamError";
62
+ }
63
+ }
64
+ /**
65
+ * Consume a completion stream to the end.
66
+ *
67
+ * The whole answer is accumulated rather than forwarded: an MCP tool result is
68
+ * one message, so there is nowhere to stream it to. The request is still made
69
+ * with `stream: true` and `include_usage`, because the trailing SSE frame is the
70
+ * only place the Gateway reports a streamed call's price — headers are written
71
+ * before the provider has counted a token.
72
+ */
73
+ export async function consume(body, onText) {
74
+ let text = "";
75
+ let usage = null;
76
+ let receipt = null;
77
+ let model = null;
78
+ let finishReason = null;
79
+ for await (const payload of sseFrames(body)) {
80
+ if (payload === "[DONE]")
81
+ break;
82
+ let frame;
83
+ try {
84
+ frame = JSON.parse(payload);
85
+ }
86
+ catch {
87
+ continue; // a half-frame is not worth ending a turn over
88
+ }
89
+ if (frame.error) {
90
+ const e = frame.error;
91
+ // A 200 whose stream carries an error. Headers are long gone by then, so
92
+ // this is the only place the gateway can report a mid-stream failure.
93
+ throw new StreamError(e?.message || "the gateway reported an error mid-stream");
94
+ }
95
+ if (typeof frame.model === "string")
96
+ model = frame.model;
97
+ if (frame.usage && typeof frame.usage === "object")
98
+ usage = frame.usage;
99
+ if (frame.x_lobstack !== undefined && frame.x_lobstack !== null)
100
+ receipt = frame.x_lobstack;
101
+ const choices = frame.choices;
102
+ const first = choices?.[0];
103
+ if (first?.finish_reason)
104
+ finishReason = first.finish_reason;
105
+ const delta = first?.delta?.content;
106
+ if (typeof delta === "string" && delta.length) {
107
+ text += delta;
108
+ onText?.(delta);
109
+ }
110
+ }
111
+ return { text, usage, receipt, model, finishReason };
112
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * lobstack_chat — one completion, and what it cost.
3
+ *
4
+ * WHY THIS STREAMS WHEN NOTHING IS STREAMED TO
5
+ *
6
+ * An MCP tool result is a single message; there is no partial delivery to an
7
+ * agent mid-call. So the whole answer is accumulated here before it is
8
+ * returned, and `stream: true` looks pointless.
9
+ *
10
+ * It is not. On the buffered path the Gateway reports the price in response
11
+ * HEADERS, and on the streamed path it reports it in the trailing SSE frame as
12
+ * `x_lobstack`. The frame is the better source: the streaming path meters
13
+ * *before* emitting that frame, so the figure is the ledger's, not an estimate,
14
+ * and it arrives as one structured object rather than eight headers, three of
15
+ * which encode "unpriced" as an empty string. Requesting SSE and reading it to
16
+ * the end is how this tool returns a receipt it did not compute itself.
17
+ *
18
+ * `stream_options.include_usage` is not optional: without it the Gateway has no
19
+ * trailing frame to attach `x_lobstack` to, and the price never arrives.
20
+ */
21
+ import { z } from "zod";
22
+ import type { Config } from "../config.js";
23
+ import { type ToolResult } from "./shared.js";
24
+ export declare const chatInput: {
25
+ prompt: z.ZodOptional<z.ZodString>;
26
+ messages: z.ZodOptional<z.ZodArray<z.ZodObject<{
27
+ role: z.ZodEnum<["system", "user", "assistant"]>;
28
+ content: z.ZodString;
29
+ }, "strip", z.ZodTypeAny, {
30
+ content: string;
31
+ role: "system" | "user" | "assistant";
32
+ }, {
33
+ content: string;
34
+ role: "system" | "user" | "assistant";
35
+ }>, "many">>;
36
+ model: z.ZodOptional<z.ZodString>;
37
+ system: z.ZodOptional<z.ZodString>;
38
+ max_tokens: z.ZodOptional<z.ZodNumber>;
39
+ temperature: z.ZodOptional<z.ZodNumber>;
40
+ };
41
+ export declare const chatOutput: {
42
+ text: z.ZodString;
43
+ model: z.ZodObject<{
44
+ requested: z.ZodNullable<z.ZodString>;
45
+ served: z.ZodNullable<z.ZodString>;
46
+ routed: z.ZodNullable<z.ZodBoolean>;
47
+ }, "strip", z.ZodTypeAny, {
48
+ routed: boolean | null;
49
+ requested: string | null;
50
+ served: string | null;
51
+ }, {
52
+ routed: boolean | null;
53
+ requested: string | null;
54
+ served: string | null;
55
+ }>;
56
+ usage: z.ZodNullable<z.ZodObject<{
57
+ prompt_tokens: z.ZodNumber;
58
+ completion_tokens: z.ZodNumber;
59
+ total_tokens: z.ZodNumber;
60
+ }, "strip", z.ZodTypeAny, {
61
+ prompt_tokens: number;
62
+ completion_tokens: number;
63
+ total_tokens: number;
64
+ }, {
65
+ prompt_tokens: number;
66
+ completion_tokens: number;
67
+ total_tokens: number;
68
+ }>>;
69
+ receipt: z.ZodNullable<z.ZodObject<{
70
+ request_id: z.ZodNullable<z.ZodString>;
71
+ cost_usd: z.ZodNullable<z.ZodNumber>;
72
+ cost_display: z.ZodString;
73
+ priced: z.ZodBoolean;
74
+ savings: z.ZodNullable<z.ZodObject<{
75
+ amount_usd: z.ZodNumber;
76
+ label: z.ZodString;
77
+ named: z.ZodBoolean;
78
+ baseline_model: z.ZodNullable<z.ZodString>;
79
+ baseline_reason: z.ZodNullable<z.ZodString>;
80
+ }, "strip", z.ZodTypeAny, {
81
+ named: boolean;
82
+ baseline_reason: string | null;
83
+ baseline_model: string | null;
84
+ amount_usd: number;
85
+ label: string;
86
+ }, {
87
+ named: boolean;
88
+ baseline_reason: string | null;
89
+ baseline_model: string | null;
90
+ amount_usd: number;
91
+ label: string;
92
+ }>>;
93
+ }, "strip", z.ZodTypeAny, {
94
+ request_id: string | null;
95
+ cost_usd: number | null;
96
+ priced: boolean;
97
+ cost_display: string;
98
+ savings: {
99
+ named: boolean;
100
+ baseline_reason: string | null;
101
+ baseline_model: string | null;
102
+ amount_usd: number;
103
+ label: string;
104
+ } | null;
105
+ }, {
106
+ request_id: string | null;
107
+ cost_usd: number | null;
108
+ priced: boolean;
109
+ cost_display: string;
110
+ savings: {
111
+ named: boolean;
112
+ baseline_reason: string | null;
113
+ baseline_model: string | null;
114
+ amount_usd: number;
115
+ label: string;
116
+ } | null;
117
+ }>>;
118
+ quota: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
119
+ dropped_params: z.ZodArray<z.ZodString, "many">;
120
+ };
121
+ type ChatArgs = {
122
+ prompt?: string;
123
+ messages?: Array<{
124
+ role: "system" | "user" | "assistant";
125
+ content: string;
126
+ }>;
127
+ model?: string;
128
+ system?: string;
129
+ max_tokens?: number;
130
+ temperature?: number;
131
+ };
132
+ export declare function runChat(cfg: Config, args: ChatArgs): Promise<ToolResult>;
133
+ export {};