@flovialabs/sdk 0.0.0-stage → 0.1.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - CLI generation output shows the response status and incomplete reason. A delivered partial answer still exits 0; no continuation is generated automatically.
6
+ - CLI status shows bought balance; usage shows server-provided bought totals and the generation subtotal without adding them twice. Free usage stays in percentages.
7
+ - Corrected consent, replay lifetime, onboarding and copyable usage examples in the README.
8
+
9
+ ## 0.1.0
10
+
11
+ First release. Flovia is in alpha; APIs may change.
12
+
13
+ - `FloviaClient`: `status()`, `usageToday()`, `listTools()`, `call(tool, args)` over MCP (HTTP), and `generate()` for OpenAI-compatible text generation with a required operation id.
14
+ - `FloviaError` with `code`, `nextStep`, `executed`, `retryAfterSeconds`, `operationId` and the full `reply`.
15
+ - Optional one-time resend of rate-limited tool calls (`retryRateLimited`).
16
+ - The `flovia` CLI: `status`, `usage`, `tools`, `call`, `generate`.
17
+ - Lost or malformed replies preserve the operation ID and unknown execution state. Keys require HTTPS outside loopback and raw transport errors are not exposed; CLI stdin preserves split UTF-8 characters.
18
+ - ESM with type declarations; no runtime dependencies. Node 20 or later, or Bun.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Flovia Labs
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.
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # @flovialabs/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ TypeScript client and command-line tool for [Flovia](https://api.flovia.dev). It calls Flovia's tools over MCP (HTTP) and its OpenAI-compatible text generation.
4
+
5
+ > **Flovia is in alpha; APIs may change.** Pin the version you use.
6
+
7
+ - ESM with type declarations, no runtime dependencies (it uses `fetch` only).
8
+ - Node.js 20 or later, or Bun.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install --save-exact @flovialabs/sdk@0.1.1
14
+ ```
15
+
16
+ ## Your key
17
+
18
+ You need an invitation to [register and receive a Flovia key](https://api.flovia.dev/join). Keep the key private; it is shown once. Follow the [connection guide](https://api.flovia.dev/) to confirm your email and start the free allowance. Contact [Flovia support](https://flovia.dev/contact) if you need access or lose your key. Installing this package does not create an account or start an allowance.
19
+
20
+ Set your Flovia key (`flv_...`) in the environment:
21
+
22
+ ```sh
23
+ export FLOVIA_KEY=flv_...
24
+ ```
25
+
26
+ Or pass it as `new FloviaClient({ apiKey })`. The client keeps the key private: it does not appear in logs, error messages or `console.log(client)`. The SDK sends no telemetry.
27
+
28
+ `baseUrl` requires HTTPS. Plain HTTP is allowed only for loopback development (`localhost`, `127.0.0.1`, `[::1]`). Raw transport diagnostics are not attached to errors because they may contain request headers.
29
+
30
+ ## Quick start
31
+
32
+ ```ts
33
+ import { FloviaClient, newOperationId } from "@flovialabs/sdk";
34
+
35
+ const flovia = new FloviaClient(); // reads FLOVIA_KEY; base URL https://api.flovia.dev
36
+
37
+ // Your free windows, services and the contract version
38
+ const status = await flovia.status();
39
+ console.log(status.five_hour.used_pct, status.week.used_pct);
40
+
41
+ // Your lookups today (or since a time within the last 7 days)
42
+ const usage = await flovia.usageToday(); // today, UTC
43
+
44
+ // Any tool: list them, then call one
45
+ const tools = await flovia.listTools();
46
+ const reply = await flovia.call("flovia_status");
47
+
48
+ // A text generation (uses credits). Save the operation id before sending.
49
+ const op = newOperationId();
50
+ const request = { model: "gpt-6-luna" as const, input: "Explain a DEX aggregator in one sentence." };
51
+ // Persist { op, request } in your application's private storage before the next line.
52
+ const g = await flovia.generate(request, { operationId: op });
53
+ console.log(g.response.status, g.response.incomplete_details, g.outputText);
54
+ ```
55
+
56
+ ### First data lookup
57
+
58
+ `listTools()` returns the tools offered to your account and their `inputSchema`. A catalog call sends nothing to the data provider. For example, list CoinGecko paths, then make one price lookup:
59
+
60
+ ```ts
61
+ await flovia.call("flovia_coingecko", {
62
+ path: "list", request_text: "Which prices can I read?", purpose: "choose a supported path",
63
+ });
64
+ const priceRequest = {
65
+ path: "/simple/price", query: { ids: "bitcoin", vs_currencies: "usd", include_last_updated_at: true },
66
+ request_text: "What is Bitcoin's USD price?", purpose: "check a price with its update time",
67
+ conversation_id: "sdk-price-example", operation_id: newOperationId(),
68
+ };
69
+ // Save priceRequest before sending; this lookup uses the shared allowance.
70
+ const price = await flovia.call("flovia_coingecko", priceRequest);
71
+ ```
72
+
73
+ If either call asks for a notice or consent, stop and follow the human-answer flow below. Read the returned source time, coverage and missing-value notes; a successful lookup is not a guarantee that it answers your question. Flovia exposes selected read-only paths, methods and public HTML hosts, not every feature of the upstream plans. Use the live catalog for the supported subset. OpenAI supports text generation with `gpt-6-luna`; it does not fetch other providers or offer image/audio generation.
74
+
75
+ ### Operation ids and replay
76
+
77
+ `generate()` requires an operation id (8-128 characters of `A-Z a-z 0-9 _ . : -`). Create one with `newOperationId()` and store it with the request **before** sending.
78
+
79
+ If the reply is lost (timeout, network error, `executed: "unknown"`), keep the request and **same** id. A completed or incomplete answer can normally be reopened for about **15 minutes** with the unchanged request, `replayed: true` and no additional charge. Follow `nextStep` for `operation_pending` or `upstream_unknown`; the answer may not yet be available. After `answer_expired` or `operation_retired`, Flovia cannot return the saved text. Never start a new id to recover a lost answer: that may run and charge twice. A genuinely new generation requires the person's decision to spend again.
80
+
81
+ ## Errors
82
+
83
+ A refusal (`ok: false`) or a failed request throws `FloviaError`:
84
+
85
+ | field | meaning |
86
+ | --- | --- |
87
+ | `code` | a public code such as `rate_limited` or `window_5h` (`PUBLIC_CODES` lists them), or `http_<status>` |
88
+ | `message` | what happened |
89
+ | `nextStep` | what to do next |
90
+ | `executed` | `"yes"`, `"no"` or `"unknown"`: whether the request ran. On `"unknown"`, keep the same operation id |
91
+ | `retryAfterSeconds` | how long to wait, when given |
92
+ | `status` | the HTTP status, when there is one |
93
+ | `operationId` | the operation id of the request |
94
+ | `reply` | the full reply body |
95
+
96
+ ### `notice_first` and `data_consent_needed`: a human must answer
97
+
98
+ These two codes are thrown on purpose. They need the person's own answer, and the SDK never answers for them.
99
+
100
+ 1. Show the full `error.reply.user_notice` unchanged. For `data_consent_needed`, also show `error.reply.consent.question`; the short question does not replace the notice.
101
+ 2. For `data_consent_needed`, get the person's own answer through your normal flow. Call `flovia_data_consent` with `answer` (`"y"` or `"n"` from that answer), `consent_version` from `error.reply.consent.version`, and `user_words` containing their actual words. Do not substitute the current SDK version or a generated answer. `notice_first` alone does not ask for a consent answer or supply a consent version.
102
+ 3. For `data_consent_needed`, repeat the unchanged call only after their `y` is accepted; with `n`, stop. For `notice_first`, repeat the same call after showing the notice, adding `notice_shown: true`.
103
+
104
+ Do not answer these automatically from code or from an agent.
105
+
106
+ ## Retries
107
+
108
+ - Generations are never resent automatically.
109
+ - With `new FloviaClient({ retryRateLimited: true })`, a tool call refused with `rate_limited`, `too_many_in_parallel` or `reservation_changed` and `executed: "no"` is resent once, unchanged, after `retry_after_seconds` (at least 1 s, at most 60 s). Off by default.
110
+ - Anything with `executed: "unknown"` is not resent.
111
+
112
+ ## Timeouts
113
+
114
+ Each request times out after 300 seconds by default (a long generation can take minutes). Change it with `timeoutMs`. A timeout throws `FloviaError` with code `timeout` and `executed: "unknown"`.
115
+
116
+ A lost or unreadable reply is also `executed: "unknown"`, even after HTTP 200. Keep the saved operation ID and request; do not start another operation to recover the answer. A retired operation is never treated as proof that nothing ran.
117
+
118
+ ## Contract version
119
+
120
+ `status()` compares the server's `contract_version` with the one this SDK was written for (`EXPECTED_CONTRACT_VERSION`) and warns once with `console.warn` when they differ. Replace the warning with `onContractMismatch`.
121
+
122
+ ## CLI
123
+
124
+ The package installs a `flovia` command.
125
+
126
+ ```sh
127
+ export FLOVIA_KEY=flv_... # or: --key-file ~/.config/flovia/key
128
+ npx flovia status
129
+ npx flovia usage --days 3
130
+ npx flovia tools
131
+ npx flovia tools --json # includes each tool's inputSchema
132
+ npx flovia call flovia_usage_today '{}'
133
+ npx flovia generate --op sdk-example-0001 --max-output 300 "Explain a DEX aggregator."
134
+ # Only to reopen that exact request within about 15 minutes (not a new generation):
135
+ npx flovia generate --op sdk-example-0001 --max-output 300 --yes "Explain a DEX aggregator."
136
+ npx flovia help
137
+ ```
138
+ These commands run after installing `@flovialabs/sdk` in the current project. Without a local installation, specify the package: `npx --package=@flovialabs/sdk@0.1.1 flovia --help`. Use a unique operation id for each new request; `sdk-example-0001` is only an example. Save the id, prompt and options before sending, and keep them unchanged for replay.
139
+
140
+ `status` shows bought balance. `usage` shows lookup counts, free percentages and server-provided bought totals; its generation subtotal is already included in the period total. Missing amounts show `-`, not zero. It does not add rounded endpoint percentages. To continue a listing, copy the printed command with its exact `--since` and `--cursor`; do not combine `--days` with `--cursor`.
141
+
142
+ - Every command takes `--json` (the raw reply), `--base-url URL`, `--timeout SECONDS` and `--key-file PATH`.
143
+ - `generate` uses credits. It needs `--op ID` or `--new-op`, and asks for confirmation on a terminal; add `--yes` in scripts.
144
+ - `generate` prints the response status and incomplete reason. `incomplete` / `max_output_tokens` means the delivered text stopped at the output limit, not that a continuation ran. Exit 0 means the response was delivered, not that the text is complete or correct. `--json` retains the full public response.
145
+ - Exit codes: 0 ok, 1 an error from Flovia, 2 a usage error.
146
+
147
+ ### Safety
148
+
149
+ - The key is read only from `FLOVIA_KEY` or a key file, never from a command-line argument (other users can see arguments in `ps`). It is never printed.
150
+ - A key file readable by other users gets a warning; use `chmod 600`.
151
+ - `--base-url` must be `https`, so the key is never sent unencrypted. Plain `http` is allowed only for `localhost`, `127.0.0.1` and `[::1]`.
152
+ - On `notice_first` or `data_consent_needed` the CLI shows the full notice and, when present, the question, then stops. The person answers it themselves; `--yes` confirms spending, not data consent.
153
+
154
+ ## License
155
+
156
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { FloviaError, type StatusReply, type UsageTodayReply } from "./index.js";
3
+ export interface Io {
4
+ env: Record<string, string | undefined>;
5
+ out: (s: string) => void;
6
+ err: (s: string) => void;
7
+ readFile: (path: string) => Promise<string>;
8
+ /** The file's permission bits, to warn when a key file is readable by others. */
9
+ fileMode?: (path: string) => Promise<number>;
10
+ readStdin: () => Promise<string>;
11
+ /** A yes/no question on a terminal; null when there is none (piped or CI). */
12
+ confirm: ((question: string) => boolean | Promise<boolean>) | null;
13
+ fetch?: (request: Request) => Promise<Response>;
14
+ now?: () => number;
15
+ }
16
+ export declare function help(command?: string): string;
17
+ export interface Parsed {
18
+ command: string;
19
+ flags: Map<string, string | true>;
20
+ positional: string[];
21
+ }
22
+ export declare function parse(argv: string[]): Parsed;
23
+ /** "Oct 18 07:21 UTC", with the year when it is not this UTC year; anything that is not a time is shown as it came. */
24
+ export declare function when(v: unknown, now?: number): string;
25
+ export declare function renderStatus(s: StatusReply, expected: string | null, now?: number): string;
26
+ export declare function renderUsage(u: UsageTodayReply, today?: boolean, now?: number): string;
27
+ /** A refusal or failure: code, next_step, executed; a handshake's notice or question is shown for the person, never answered here. */
28
+ export declare function renderError(e: FloviaError): string;
29
+ export declare function run(argv: string[], io: Io): Promise<number>;
30
+ /** Decode UTF-8 across chunks, including a character split between reads. */
31
+ export declare function readStdin(input: NodeJS.ReadableStream): Promise<string>;
package/dist/cli.js ADDED
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * flovia: a small CLI over the SDK's public client, for people to check their status and usage and try calls with their own key.
4
+ * The key comes from FLOVIA_KEY or --key-file only (never argv, which ps shows), and is never printed. Exit 2: usage error; 1: a relay error.
5
+ */
6
+ import { readFile, stat } from "node:fs/promises";
7
+ import { realpathSync } from "node:fs";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { fileURLToPath } from "node:url";
10
+ import { FloviaClient, FloviaError, newOperationId } from "./index.js";
11
+ const GLOBAL = "[--json] [--base-url URL] [--timeout SECONDS] [--key-file PATH]";
12
+ const COMMANDS = {
13
+ status: { usage: "status", about: "free windows, services, contract version and recent updates", flags: {} },
14
+ usage: { usage: "usage [--since ISO | --days N] [--cursor C]", about: "lookups and generations (today, or since a time within 7 days)", flags: { "--since": true, "--days": true, "--cursor": true } },
15
+ tools: { usage: "tools", about: "the tools this key is offered", flags: {} },
16
+ call: { usage: "call <tool> ['<json args>']", about: "call any tool and print its reply", flags: {} },
17
+ generate: { usage: "generate (--op ID | --new-op) [--max-output N] [--yes] <prompt | ->", about: "a text generation (uses credits)", flags: { "--op": true, "--new-op": false, "--max-output": true, "--yes": false } },
18
+ help: { usage: "help [command]", about: "this help", flags: {} },
19
+ };
20
+ const GLOBAL_FLAGS = { "--json": false, "--base-url": true, "--timeout": true, "--key-file": true, "--help": false, "-h": false };
21
+ class Usage extends Error {
22
+ }
23
+ function distance(a, b) {
24
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j);
25
+ for (let i = 1; i <= a.length; i++) {
26
+ const next = [i];
27
+ for (let j = 1; j <= b.length; j++)
28
+ next[j] = Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1));
29
+ row = next;
30
+ }
31
+ return row[b.length] ?? 0;
32
+ }
33
+ const near = (word, options) => options.find((k) => distance(k, word) <= 2 || k.startsWith(word));
34
+ const didYouMean = (word, options, quote = "") => { const n = near(word, options); return n === undefined ? "" : `; did you mean ${quote}${n}${quote}?`; };
35
+ export function help(command) {
36
+ const c = command === undefined ? undefined : COMMANDS[command];
37
+ if (c !== undefined)
38
+ return `Usage: flovia ${c.usage} ${GLOBAL}\n ${c.about}\n`;
39
+ return ["Usage: flovia <command> " + GLOBAL, "",
40
+ ...Object.values(COMMANDS).map((x) => ` ${x.usage.padEnd(68)} ${x.about}`), "",
41
+ "The key is read from FLOVIA_KEY, or from the file given with --key-file. It is never accepted as an argument.",
42
+ "Default base URL: https://api.flovia.dev. Exit codes: 0 ok, 1 relay error, 2 usage error.", ""].join("\n");
43
+ }
44
+ export function parse(argv) {
45
+ const [command, ...rest] = argv;
46
+ if (command === undefined || command === "--help" || command === "-h")
47
+ return { command: "help", flags: new Map(), positional: [] };
48
+ const spec = COMMANDS[command];
49
+ if (spec === undefined)
50
+ throw new Usage(`unknown command "${command}"${didYouMean(command, Object.keys(COMMANDS), '"')} Run: flovia help`);
51
+ const known = { ...GLOBAL_FLAGS, ...spec.flags }, flags = new Map(), positional = [];
52
+ for (let i = 0; i < rest.length; i++) {
53
+ const a = rest[i] ?? "";
54
+ if (a === "-" || !a.startsWith("-")) {
55
+ positional.push(a);
56
+ continue;
57
+ }
58
+ const eq = a.indexOf("="), name = eq > 0 ? a.slice(0, eq) : a;
59
+ if (!(name in known))
60
+ throw new Usage(`${command}: unknown option ${name}${didYouMean(name, Object.keys(known))} Usage: flovia ${spec.usage}`);
61
+ if (!known[name]) {
62
+ if (eq > 0)
63
+ throw new Usage(`${command}: ${name} takes no value`);
64
+ flags.set(name, true);
65
+ continue;
66
+ }
67
+ const value = eq > 0 ? a.slice(eq + 1) : rest[++i];
68
+ if (value === undefined)
69
+ throw new Usage(`${command}: ${name} needs a value. Usage: flovia ${spec.usage}`);
70
+ flags.set(name, value);
71
+ }
72
+ if (flags.has("--help") || flags.has("-h"))
73
+ return { command: "help", flags: new Map(), positional: [command] };
74
+ return { command, flags, positional };
75
+ }
76
+ const str = (v) => typeof v === "string" ? v : undefined;
77
+ function positiveInt(name, v, max = Number.MAX_SAFE_INTEGER) {
78
+ if (v === undefined)
79
+ return undefined;
80
+ const n = Number(v);
81
+ if (!Number.isInteger(n) || n < 1 || n > max)
82
+ throw new Usage(`${name} must be a whole number from 1 to ${max}`);
83
+ return n;
84
+ }
85
+ /** The key from --key-file (trimmed) or FLOVIA_KEY. Its text never goes into a message. */
86
+ async function readKey(p, io) {
87
+ const file = str(p.flags.get("--key-file"));
88
+ if (file !== undefined) {
89
+ let text;
90
+ try {
91
+ text = await io.readFile(file);
92
+ }
93
+ catch {
94
+ throw new Usage(`--key-file: cannot read ${file}`);
95
+ }
96
+ const key = text.trim();
97
+ if (key === "")
98
+ throw new Usage(`--key-file: ${file} is empty`);
99
+ const mode = await io.fileMode?.(file).catch(() => null);
100
+ if (typeof mode === "number" && (mode & 0o077) !== 0)
101
+ io.err(`warning: ${file} is readable by other users (mode ${(mode & 0o777).toString(8)}); run: chmod 600 ${file}\n`);
102
+ return key;
103
+ }
104
+ const key = io.env["FLOVIA_KEY"]?.trim();
105
+ if (!key)
106
+ throw new Usage("No key: set FLOVIA_KEY or pass --key-file PATH (the key is never accepted as an argument).");
107
+ return key;
108
+ }
109
+ const pct = (v) => typeof v === "number" ? `${v}%` : "-";
110
+ const table = (rows) => {
111
+ const widths = rows[0]?.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length))) ?? [];
112
+ return rows.map((r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ")).join("\n") + "\n";
113
+ };
114
+ /** "Oct 18 07:21 UTC", with the year when it is not this UTC year; anything that is not a time is shown as it came. */
115
+ export function when(v, now = Date.now()) {
116
+ const d = new Date(String(v));
117
+ if (v === null || v === undefined || Number.isNaN(d.getTime()))
118
+ return String(v);
119
+ const [, day, mon, year, hm] = d.toUTCString().split(" ");
120
+ return `${mon} ${day}${d.getUTCFullYear() === new Date(now).getUTCFullYear() ? "" : ` ${year}`} ${hm?.slice(0, 5)} UTC`;
121
+ }
122
+ const obj = (v) => v !== null && typeof v === "object" ? v : {};
123
+ export function renderStatus(s, expected, now = Date.now()) {
124
+ const lines = [`Flovia ${s.stage}, contract ${s.contract_version}${expected === null ? "" : ` (warning: this CLI expects ${expected})`}`];
125
+ for (const [label, w] of [["5-hour window", s.five_hour], ["week", s.week]])
126
+ lines.push(`${label.padEnd(14)} ${pct(w?.used_pct)} used${w?.resets_at ? `, resets ${when(w.resets_at, now)}` : label === "5-hour window" ? ", not started" : ""}`);
127
+ lines.push(`bought balance ${String(s["purchased_balance_display"] ?? "-")}`);
128
+ const pending = obj(s["free_pending"]);
129
+ if (pending["next_step"])
130
+ lines.push(`free use not started: ${String(pending["next_step"])}`);
131
+ const stopped = obj(s["stopped"]);
132
+ if (stopped["message"])
133
+ lines.push(`key stopped: ${String(stopped["message"])}`);
134
+ const services = Object.entries(obj(s["services"])).map(([name, v]) => { const x = obj(v); return `${name} ${String(x["state"] ?? "?")}${x["until"] ? ` until ${when(x["until"], now)}` : ""}`; });
135
+ lines.push(`services ${services.length ? services.join(", ") : "-"}`);
136
+ lines.push(`turned off ${s.services_off.length ? s.services_off.join(", ") : "none"}`);
137
+ const consent = obj(s["data_consent"]);
138
+ if (consent["needed"] === true)
139
+ lines.push("data consent needed: lookups wait for the person's own y/n (answer through the normal flow, not this CLI)");
140
+ const updates = Array.isArray(s["recent_updates"]) ? s["recent_updates"] : [];
141
+ for (const u of updates.slice(0, 2)) {
142
+ const x = obj(u), first = Array.isArray(x["lines"]) ? String(x["lines"][0] ?? "") : "";
143
+ lines.push(`update ${String(x["version"])} (${String(x["date"])}): ${first}`);
144
+ }
145
+ if (updates.length > 2)
146
+ lines.push(`+${updates.length - 2} more (--json)`);
147
+ return lines.join("\n") + "\n";
148
+ }
149
+ export function renderUsage(u, today = false, now = Date.now()) {
150
+ const lines = [`since ${when(u["since"], now)} until ${when(u["until"], now)}${today ? " (today, UTC; use --days 7 for the week)" : ""}`];
151
+ const week = obj(u["free_week"]);
152
+ lines.push(`free week: ${pct(week["used_pct"])} used${week["resets_at"] ? `, resets ${when(week["resets_at"], now)}` : ""}`);
153
+ lines.push(`total in this period: ${String(obj(u["total"])["free_display"] ?? "-")} of the free week, ${String(u["count"] ?? 0)} lookups`);
154
+ lines.push(`bought credits used: ${String(obj(u["total"])["bought_display"] ?? "-")} total (includes generations)`);
155
+ // Endpoint percentages are rounded (0 can mean <1%); only counts can be summed by source.
156
+ const bySource = new Map();
157
+ for (const e of Array.isArray(u["by_endpoint"]) ? u["by_endpoint"] : []) {
158
+ const x = obj(e), name = String(x["provider"]);
159
+ bySource.set(name, (bySource.get(name) ?? 0) + Number(x["count"] ?? 0));
160
+ }
161
+ if (bySource.size)
162
+ lines.push("", table([["source", "lookups"], ...[...bySource].map(([n, count]) => [n, String(count)])]).trimEnd());
163
+ const g = obj(u["generations"]);
164
+ lines.push("", `generations: ${String(g["operations"] ?? 0)} (completed ${String(g["completed"] ?? 0)}, incomplete ${String(g["incomplete"] ?? 0)}, unknown ${String(g["unknown"] ?? 0)}), ${String(g["free_display"] ?? "-")} of the free week, bought ${String(g["bought_display"] ?? "-")} (included in total)`);
165
+ if (u.has_more)
166
+ lines.push("", `more lookups: rerun flovia usage with the same --key-file and --base-url options, adding --since ${String(u["since"])} --cursor ${String(u.next_cursor)}`);
167
+ return lines.join("\n") + "\n";
168
+ }
169
+ /** A refusal or failure: code, next_step, executed; a handshake's notice or question is shown for the person, never answered here. */
170
+ export function renderError(e) {
171
+ const lines = [`error: ${e.code}: ${e.message}`];
172
+ if (e.nextStep)
173
+ lines.push(`next_step: ${e.nextStep}`);
174
+ lines.push(`executed: ${e.executed}`);
175
+ if (e.retryAfterSeconds !== null)
176
+ lines.push(`retry_after_seconds: ${e.retryAfterSeconds}`);
177
+ if (e.operationId)
178
+ lines.push(`operation_id: ${e.operationId}${e.executed === "unknown" ? " (resend with this same id; never a new one)" : ""}`);
179
+ const reply = obj(e.reply);
180
+ if (e.code === "notice_first" || e.code === "data_consent_needed") {
181
+ if (reply["user_notice"])
182
+ lines.push("", "Notice for the person:", String(reply["user_notice"]));
183
+ const question = obj(reply["consent"])["question"];
184
+ if (question)
185
+ lines.push("", `Question for the person: ${String(question)}`);
186
+ lines.push("", "The person must answer this themselves through the normal flow (their agent and Flovia). This CLI does not answer it.");
187
+ }
188
+ return lines.join("\n") + "\n";
189
+ }
190
+ export async function run(argv, io) {
191
+ let key = null;
192
+ // Belt and braces: whatever is printed passes through here, so the key cannot appear even if a reply or error echoed it.
193
+ const scrub = (s) => key === null ? s : s.replaceAll(key, "[key]");
194
+ const out = (s) => io.out(scrub(s)), err = (s) => io.err(scrub(s));
195
+ let p;
196
+ try {
197
+ p = parse(argv);
198
+ if (p.command === "help") {
199
+ out(help(p.positional[0]));
200
+ return 0;
201
+ }
202
+ key = await readKey(p, io);
203
+ }
204
+ catch (e) {
205
+ if (e instanceof Usage) {
206
+ err(`${e.message}\n`);
207
+ return 2;
208
+ }
209
+ throw e;
210
+ }
211
+ const json = p.flags.has("--json"), print = (v) => out(JSON.stringify(v, null, 2) + "\n");
212
+ let mismatch = null;
213
+ try {
214
+ const timeout = positiveInt("--timeout", str(p.flags.get("--timeout")));
215
+ const baseUrl = str(p.flags.get("--base-url"));
216
+ const client = new FloviaClient({ apiKey: key, ...(baseUrl !== undefined ? { baseUrl } : {}), ...(timeout ? { timeoutMs: timeout * 1000 } : {}),
217
+ ...(io.fetch ? { fetch: io.fetch } : {}), onContractMismatch: (expected) => { mismatch = expected; } });
218
+ switch (p.command) {
219
+ case "status": {
220
+ const s = await client.status();
221
+ if (json)
222
+ print(s);
223
+ else
224
+ out(renderStatus(s, mismatch, (io.now ?? Date.now)()));
225
+ if (json && mismatch !== null)
226
+ err(`warning: contract ${s.contract_version} differs from this CLI's ${String(mismatch)}\n`);
227
+ return 0;
228
+ }
229
+ case "usage": {
230
+ const since = str(p.flags.get("--since")), days = positiveInt("--days", str(p.flags.get("--days")), 7), cursor = str(p.flags.get("--cursor"));
231
+ if (since !== undefined && days !== undefined)
232
+ throw new Usage("usage: pass --since or --days, not both");
233
+ if (cursor !== undefined && days !== undefined)
234
+ throw new Usage("usage: with --cursor, use the previous reply's exact --since, not --days");
235
+ if (since !== undefined && Number.isNaN(Date.parse(since)))
236
+ throw new Usage("--since must be an ISO 8601 time");
237
+ // A minute's margin so --days 7 stays inside the relay's 7 days by the time it arrives.
238
+ const from = days === undefined ? since : new Date((io.now ?? Date.now)() - days * 86_400_000 + 60_000).toISOString();
239
+ const u = await client.usageToday({ ...(from ? { since: from } : {}), ...(cursor ? { cursor } : {}) });
240
+ if (json)
241
+ print(u);
242
+ else
243
+ out(renderUsage(u, from === undefined && cursor === undefined, (io.now ?? Date.now)()));
244
+ return 0;
245
+ }
246
+ case "tools": {
247
+ const tools = await client.listTools();
248
+ if (json)
249
+ print(tools);
250
+ else
251
+ out(table(tools.map((t) => [t.name, (t.description ?? "").split(/(?<=\.)\s/)[0] ?? ""])));
252
+ return 0;
253
+ }
254
+ case "call": {
255
+ const [tool, raw, extra] = p.positional;
256
+ if (tool === undefined || extra !== undefined)
257
+ throw new Usage(`Usage: flovia ${COMMANDS["call"]?.usage}`);
258
+ let args = {};
259
+ if (raw !== undefined)
260
+ try {
261
+ args = JSON.parse(raw);
262
+ }
263
+ catch {
264
+ throw new Usage("call: the arguments must be one JSON object, e.g. '{\"since\":\"2026-10-10T00:00:00Z\"}'");
265
+ }
266
+ if (args === null || typeof args !== "object" || Array.isArray(args))
267
+ throw new Usage("call: the arguments must be a JSON object");
268
+ print(await client.call(tool, args));
269
+ return 0;
270
+ }
271
+ case "generate": {
272
+ const op = str(p.flags.get("--op")), fresh = p.flags.has("--new-op");
273
+ if ((op === undefined) === !fresh)
274
+ throw new Usage("generate: pass --op ID (to resend, the same id) or --new-op (to make one); exactly one of them");
275
+ const [text, extra] = p.positional;
276
+ if (text === undefined || extra !== undefined)
277
+ throw new Usage(`Usage: flovia ${COMMANDS["generate"]?.usage}`);
278
+ const input = text === "-" ? await io.readStdin() : text;
279
+ if (input.trim() === "")
280
+ throw new Usage("generate: the prompt is empty");
281
+ const max = positiveInt("--max-output", str(p.flags.get("--max-output")));
282
+ const id = op ?? newOperationId();
283
+ // The id is shown before anything is sent, so a lost reply can be reopened with it (never resent under a new one).
284
+ if (fresh)
285
+ err(`operation_id: ${id}\nSave it. To resend or reopen this request, use: --op ${id}\n`);
286
+ err("This generation uses Flovia credits (free windows first, then bought credits). The same --op and unchanged request can reopen a saved answer for about 15 minutes at no charge. Pending, unknown or expired answers must not be replaced with a new id.\n");
287
+ if (!p.flags.has("--yes")) {
288
+ if (io.confirm === null || text === "-")
289
+ throw new Usage("generate: add --yes to send (no terminal to confirm on)");
290
+ if (!(await io.confirm("Send it?"))) {
291
+ err("Not sent.\n");
292
+ return 2;
293
+ }
294
+ }
295
+ const g = await client.generate({ model: "gpt-6-luna", input, ...(max ? { max_output_tokens: max } : {}) }, { operationId: id });
296
+ const reason = obj(g.response["incomplete_details"])["reason"];
297
+ if (json)
298
+ print(g);
299
+ else
300
+ out(`${g.outputText}\n\n[status: ${String(g.response["status"] ?? "unknown")}${reason ? `, reason: ${String(reason)}` : ""}, operation_id ${id}${g.replayed ? ", replayed: the saved answer, not charged again" : ""}${g.generationId ? `, generation ${g.generationId}` : ""}]\n`);
301
+ return 0;
302
+ }
303
+ }
304
+ return 2;
305
+ }
306
+ catch (e) {
307
+ if (e instanceof Usage) {
308
+ err(`${e.message}\n`);
309
+ return 2;
310
+ }
311
+ if (e instanceof FloviaError) {
312
+ if (json && e.reply)
313
+ print(e.reply);
314
+ err(renderError(e));
315
+ return e.code === "invalid_base_url" ? 2 : 1;
316
+ }
317
+ err(`error: ${e instanceof Error ? e.message : String(e)}\n`);
318
+ return 1;
319
+ }
320
+ }
321
+ /** Decode UTF-8 across chunks, including a character split between reads. */
322
+ export async function readStdin(input) {
323
+ input.setEncoding("utf8");
324
+ let text = "";
325
+ for await (const chunk of input)
326
+ text += String(chunk);
327
+ return text;
328
+ }
329
+ /** Run only as the program (also through npm's bin symlink), not when imported. Node and Bun alike. */
330
+ const isMain = (() => { try {
331
+ return realpathSync(process.argv[1] ?? "") === realpathSync(fileURLToPath(import.meta.url));
332
+ }
333
+ catch {
334
+ return false;
335
+ } })();
336
+ if (isMain) {
337
+ const tty = process.stdin.isTTY === true;
338
+ const confirm = async (q) => {
339
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
340
+ try {
341
+ return /^y(es)?$/i.test((await rl.question(`${q} [y/N] `)).trim());
342
+ }
343
+ finally {
344
+ rl.close();
345
+ }
346
+ };
347
+ process.exitCode = await run(process.argv.slice(2), {
348
+ env: process.env, out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s),
349
+ readFile: (path) => readFile(path, "utf8"), fileMode: async (path) => (await stat(path)).mode, readStdin: () => readStdin(process.stdin),
350
+ confirm: tty ? confirm : null,
351
+ });
352
+ }
@@ -0,0 +1,45 @@
1
+ import { type Generation, type GenerationRequest, type StatusReply, type ToolReply, type UsageTodayArgs, type UsageTodayReply } from "./types.js";
2
+ export interface FloviaClientOptions {
3
+ /** A Flovia key (flv_...). Defaults to the FLOVIA_KEY environment variable. Never logged. */
4
+ apiKey?: string;
5
+ /** Defaults to https://api.flovia.dev. */
6
+ baseUrl?: string;
7
+ /** Per request, in milliseconds. Defaults to 300 s: a long generation takes minutes. */
8
+ timeoutMs?: number;
9
+ fetch?: (request: Request) => Promise<Response>;
10
+ /**
11
+ * Resend a tool call once, unchanged (same operation_id), after rate_limited, too_many_in_parallel or reservation_changed with executed
12
+ * "no", when the wait is at most 60 s; it waits at least 1 s. Off by default. Generations are never resent automatically.
13
+ */
14
+ retryRateLimited?: boolean;
15
+ /** Called once when flovia_status reports another contract version. Defaults to console.warn. */
16
+ onContractMismatch?: (expected: string, actual: string) => void;
17
+ }
18
+ export interface ToolInfo {
19
+ name: string;
20
+ description?: string;
21
+ inputSchema: Record<string, unknown>;
22
+ }
23
+ /** A new operation_id. Save it with the request before sending, so a lost reply can be reopened with the same id. */
24
+ export declare const newOperationId: () => string;
25
+ export declare class FloviaClient {
26
+ #private;
27
+ constructor(options?: FloviaClientOptions);
28
+ /** tools/list: the tools this key is offered, with their input schemas. */
29
+ listTools(): Promise<ToolInfo[]>;
30
+ /** tools/call for any tool. A reply with ok: false throws FloviaError (handshakes such as notice_first too; see error.reply). */
31
+ call<R extends ToolReply = ToolReply>(tool: string, args?: Record<string, unknown>): Promise<R>;
32
+ /** flovia_status. Warns once when the relay's contract_version is not the one this SDK expects. */
33
+ status(): Promise<StatusReply>;
34
+ /** flovia_usage_today: the user's lookups (today, or since a time within 7 days). */
35
+ usageToday(args?: UsageTodayArgs): Promise<UsageTodayReply>;
36
+ /**
37
+ * POST /openai/v1/responses. operationId is required: save it with the request before calling. Sending the same request with the same id
38
+ * again can return the saved answer (replayed: true) for about 15 minutes at no charge. Pending, unknown and expired answers
39
+ * are not proof that nothing ran. Never resent automatically, and never under a new id to recover an answer.
40
+ */
41
+ generate(request: GenerationRequest, options: {
42
+ operationId: string;
43
+ conversationId?: string;
44
+ }): Promise<Generation>;
45
+ }
package/dist/client.js ADDED
@@ -0,0 +1,185 @@
1
+ import { FloviaError } from "./errors.js";
2
+ import { EXPECTED_CONTRACT_VERSION } from "./types.js";
3
+ /** The relay's operation_id shape: 8-128 of A-Z a-z 0-9 _ . : - */
4
+ const OPERATION_ID = /^[A-Za-z0-9_.:-]{8,128}$/;
5
+ const RESEND_CODES = new Set(["rate_limited", "too_many_in_parallel", "reservation_changed"]);
6
+ const MAX_RESEND_WAIT_S = 60;
7
+ /** A new operation_id. Save it with the request before sending, so a lost reply can be reopened with the same id. */
8
+ export const newOperationId = () => `op_${crypto.randomUUID()}`;
9
+ const envKey = () => globalThis.process?.env?.["FLOVIA_KEY"];
10
+ const seconds = (v) => {
11
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
12
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : null;
13
+ };
14
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ const SAME_OPERATION = "Resend with the same operation_id; do not use a new one.";
16
+ /** A lost or unreadable reply cannot prove that the operation did not run. */
17
+ async function readReply(r, operationId, sse = false) {
18
+ try {
19
+ const text = (await r.text()).trimStart();
20
+ const payload = sse && !text.startsWith("{") ? text.split(/\r?\n/).filter((l) => l.startsWith("data:"))
21
+ .map((l) => l.slice(5).replace(/^ /, "")).join("\n") : text;
22
+ const body = JSON.parse(payload);
23
+ if (body === null || typeof body !== "object" || Array.isArray(body))
24
+ throw new Error("invalid reply");
25
+ return body;
26
+ }
27
+ catch (cause) {
28
+ const timeout = cause instanceof Error && cause.name === "TimeoutError";
29
+ throw new FloviaError({ code: timeout ? "timeout" : `http_${r.status}`, status: r.status, executed: "unknown", operationId,
30
+ message: timeout ? "Timed out while reading the reply." : `Unexpected reply (HTTP ${r.status}).`, nextStep: operationId ? SAME_OPERATION : null });
31
+ }
32
+ }
33
+ export class FloviaClient {
34
+ #key;
35
+ #base;
36
+ #timeoutMs;
37
+ #fetch;
38
+ #resend;
39
+ #onMismatch;
40
+ #warned = false;
41
+ #id = 0;
42
+ constructor(options = {}) {
43
+ const key = options.apiKey ?? envKey();
44
+ if (!key)
45
+ throw new FloviaError({ code: "missing_api_key", message: "Pass apiKey or set FLOVIA_KEY.", executed: "no" });
46
+ this.#key = key;
47
+ const base = options.baseUrl ?? "https://api.flovia.dev";
48
+ let url;
49
+ try {
50
+ url = new URL(base);
51
+ }
52
+ catch {
53
+ throw new FloviaError({ code: "invalid_base_url", executed: "no", message: "baseUrl / --base-url must be a valid URL." });
54
+ }
55
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
56
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
57
+ throw new FloviaError({ code: "invalid_base_url", executed: "no", message: "baseUrl / --base-url must be https (plain http only for localhost, 127.0.0.1 or [::1])." });
58
+ this.#base = base.replace(/\/+$/, "");
59
+ this.#timeoutMs = options.timeoutMs ?? 300_000;
60
+ this.#fetch = options.fetch ?? ((r) => fetch(r));
61
+ this.#resend = options.retryRateLimited ?? false;
62
+ // eslint-disable-next-line no-console -- versions only, never the key
63
+ this.#onMismatch = options.onContractMismatch ?? ((expected, actual) => console.warn(`Flovia contract ${actual} differs from the SDK's ${expected}; check flovia_status recent_updates.`));
64
+ }
65
+ /** One HTTP request. A network failure or timeout is executed "unknown": the relay may have received it. */
66
+ async #send(path, body, headers = {}, operationId = null) {
67
+ const request = new Request(this.#base + path, { method: "POST", body: JSON.stringify(body), signal: AbortSignal.timeout(this.#timeoutMs),
68
+ headers: { Authorization: `Bearer ${this.#key}`, "Content-Type": "application/json", ...headers } });
69
+ let r;
70
+ try {
71
+ r = await this.#fetch(request);
72
+ }
73
+ catch (cause) {
74
+ const timeout = cause instanceof Error && cause.name === "TimeoutError";
75
+ // Transport diagnostics may contain request headers, so never attach their raw cause.
76
+ throw new FloviaError({ code: timeout ? "timeout" : "network_error", executed: "unknown", operationId,
77
+ message: timeout ? `No reply within ${this.#timeoutMs} ms.` : "The request did not complete.",
78
+ nextStep: operationId ? SAME_OPERATION : null });
79
+ }
80
+ if (r.status === 401)
81
+ throw new FloviaError({ code: "unauthorized", status: 401, executed: "no", message: "The Flovia key was not accepted." });
82
+ return r;
83
+ }
84
+ async #rpc(method, params, operationId = null) {
85
+ const r = await this.#send("/mcp", { jsonrpc: "2.0", id: ++this.#id, method, ...(params ? { params } : {}) }, { Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-06-18" }, operationId);
86
+ const message = await readReply(r, operationId, true);
87
+ if (message.error !== undefined || message.result === null || typeof message.result !== "object" || Array.isArray(message.result))
88
+ throw new FloviaError({ code: "mcp_error", status: r.status, executed: "unknown", operationId,
89
+ message: String(message.error?.message ?? "No result."), nextStep: operationId ? SAME_OPERATION : null });
90
+ return message.result;
91
+ }
92
+ /** tools/list: the tools this key is offered, with their input schemas. */
93
+ async listTools() {
94
+ return (await this.#rpc("tools/list")).tools;
95
+ }
96
+ /** tools/call for any tool. A reply with ok: false throws FloviaError (handshakes such as notice_first too; see error.reply). */
97
+ async call(tool, args = {}) {
98
+ const operationId = typeof args["operation_id"] === "string" ? args["operation_id"] : null;
99
+ for (let attempt = 0;; attempt++) {
100
+ const result = await this.#rpc("tools/call", { name: tool, arguments: args }, operationId);
101
+ const text = result.content?.[0]?.text;
102
+ let reply;
103
+ try {
104
+ reply = JSON.parse(String(text));
105
+ if (reply === null || typeof reply !== "object" || Array.isArray(reply) || (reply.ok === false &&
106
+ (reply.explanation === null || typeof reply.explanation !== "object")))
107
+ throw new Error("invalid tool reply");
108
+ if (reply.ok === false) {
109
+ const e = reply.explanation;
110
+ if (Array.isArray(e) || typeof e?.code !== "string" || typeof e.meaning !== "string" || typeof e.next_step !== "string" ||
111
+ !["yes", "no", "unknown"].includes(e.executed))
112
+ throw new Error("invalid explanation");
113
+ }
114
+ }
115
+ catch {
116
+ throw new FloviaError({ code: "tool_error", executed: "unknown", operationId, nextStep: operationId ? SAME_OPERATION : null,
117
+ message: result.isError ? "The tool failed." : "The tool reply is not JSON." });
118
+ }
119
+ const e = reply.explanation;
120
+ if (reply.ok !== false || e === undefined)
121
+ return reply;
122
+ const wait = seconds(reply.retry_after_seconds);
123
+ // Only a refusal that surely did not run; at least 1 s apart, so a missing or 0 wait never resends at once.
124
+ if (this.#resend && attempt === 0 && e.executed === "no" && RESEND_CODES.has(e.code) && (wait ?? 0) <= MAX_RESEND_WAIT_S) {
125
+ await sleep(Math.max(1, wait ?? 0) * 1000);
126
+ continue;
127
+ }
128
+ throw new FloviaError({ code: e.code, message: e.meaning, nextStep: e.next_step, executed: e.executed, retryAfterSeconds: wait, operationId, reply });
129
+ }
130
+ }
131
+ /** flovia_status. Warns once when the relay's contract_version is not the one this SDK expects. */
132
+ async status() {
133
+ const s = await this.call("flovia_status");
134
+ if (!this.#warned && s.contract_version !== EXPECTED_CONTRACT_VERSION) {
135
+ this.#warned = true;
136
+ this.#onMismatch(EXPECTED_CONTRACT_VERSION, s.contract_version);
137
+ }
138
+ return s;
139
+ }
140
+ /** flovia_usage_today: the user's lookups (today, or since a time within 7 days). */
141
+ usageToday(args = {}) {
142
+ return this.call("flovia_usage_today", { ...args });
143
+ }
144
+ /**
145
+ * POST /openai/v1/responses. operationId is required: save it with the request before calling. Sending the same request with the same id
146
+ * again can return the saved answer (replayed: true) for about 15 minutes at no charge. Pending, unknown and expired answers
147
+ * are not proof that nothing ran. Never resent automatically, and never under a new id to recover an answer.
148
+ */
149
+ async generate(request, options) {
150
+ const op = options.operationId;
151
+ if (!OPERATION_ID.test(op))
152
+ throw new FloviaError({ code: "invalid_operation_id", executed: "no", message: "operationId must be 8-128 of A-Z a-z 0-9 _ . : -" });
153
+ const r = await this.#send("/openai/v1/responses", request, { "Flovia-Operation-Id": op, ...(options.conversationId ? { "Flovia-Conversation-Id": options.conversationId } : {}) }, op);
154
+ const body = await readReply(r, op);
155
+ if (r.status === 200 && !Array.isArray(body["output"]))
156
+ throw new FloviaError({ code: "http_200", status: 200, executed: "unknown", operationId: op, message: "Unexpected generation reply (HTTP 200).", nextStep: SAME_OPERATION });
157
+ if (r.status === 200)
158
+ return { response: body, outputText: outputText(body), generationId: r.headers.get("Flovia-Generation-Id"),
159
+ replayed: r.headers.get("Flovia-Replayed") === "1", receivedAt: r.headers.get("Flovia-Received-At"), conversationId: r.headers.get("Flovia-Conversation-Id") };
160
+ const err = body["error"], explanation = body["explanation"];
161
+ const code = typeof err === "string" ? err : String(err?.code ?? `http_${r.status}`);
162
+ const message = String(err?.message ?? body["message"] ?? `HTTP ${r.status}`);
163
+ const executed = explanation?.executed ?? generationExecuted(code);
164
+ throw new FloviaError({ code, message, status: r.status, operationId: op, reply: body,
165
+ executed, nextStep: explanation?.next_step ?? (executed === "unknown" ? SAME_OPERATION : null),
166
+ retryAfterSeconds: seconds(r.headers.get("Retry-After")) ?? seconds(body["retry_after_seconds"]) });
167
+ }
168
+ }
169
+ /** Whether a refused generation ran, from its public code (the same reading flovia_openai gives over MCP). */
170
+ function generationExecuted(code) {
171
+ if (["answer_unavailable", "answer_expired", "answer_delivery_denied"].includes(code))
172
+ return "yes";
173
+ if (["generation_unknown", "generation_pending", "record_unavailable", "operation_retired"].includes(code) || /^http_5\d\d$/.test(code))
174
+ return "unknown";
175
+ return "no";
176
+ }
177
+ /** The output_text parts of output[].content[], in order. */
178
+ function outputText(body) {
179
+ const output = Array.isArray(body["output"]) ? body["output"] : [];
180
+ return output.flatMap((item) => {
181
+ const content = item?.content;
182
+ return Array.isArray(content) ? content : [];
183
+ }).filter((c) => c?.type === "output_text" && typeof c.text === "string")
184
+ .map((c) => c.text).join("");
185
+ }
@@ -0,0 +1,27 @@
1
+ import type { Executed, ToolReply } from "./types.js";
2
+ /**
3
+ * A Flovia refusal or a failed request. code is the relay's public code (or an HTTP generation code such as generation_pending).
4
+ * executed "unknown": keep the same operation_id; never send the request again under a new one. The message never carries the key.
5
+ */
6
+ export declare class FloviaError extends Error {
7
+ readonly name = "FloviaError";
8
+ readonly code: string;
9
+ readonly nextStep: string | null;
10
+ readonly executed: Executed;
11
+ readonly status: number | null;
12
+ readonly retryAfterSeconds: number | null;
13
+ readonly operationId: string | null;
14
+ /** The full reply body, for fields such as user_notice or consent.question. */
15
+ readonly reply: ToolReply | null;
16
+ constructor(init: {
17
+ code: string;
18
+ message: string;
19
+ nextStep?: string | null;
20
+ executed: Executed;
21
+ status?: number | null;
22
+ retryAfterSeconds?: number | null;
23
+ operationId?: string | null;
24
+ reply?: ToolReply | null;
25
+ cause?: unknown;
26
+ });
27
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A Flovia refusal or a failed request. code is the relay's public code (or an HTTP generation code such as generation_pending).
3
+ * executed "unknown": keep the same operation_id; never send the request again under a new one. The message never carries the key.
4
+ */
5
+ export class FloviaError extends Error {
6
+ name = "FloviaError";
7
+ code;
8
+ nextStep;
9
+ executed;
10
+ status;
11
+ retryAfterSeconds;
12
+ operationId;
13
+ /** The full reply body, for fields such as user_notice or consent.question. */
14
+ reply;
15
+ constructor(init) {
16
+ super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
17
+ this.code = init.code;
18
+ this.nextStep = init.nextStep ?? null;
19
+ this.executed = init.executed;
20
+ this.status = init.status ?? null;
21
+ this.retryAfterSeconds = init.retryAfterSeconds ?? null;
22
+ this.operationId = init.operationId ?? null;
23
+ this.reply = init.reply ?? null;
24
+ }
25
+ }
@@ -0,0 +1,3 @@
1
+ export { FloviaClient, newOperationId, type FloviaClientOptions, type ToolInfo } from "./client.js";
2
+ export { FloviaError } from "./errors.js";
3
+ export * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { FloviaClient, newOperationId } from "./client.js";
2
+ export { FloviaError } from "./errors.js";
3
+ export * from "./types.js";
@@ -0,0 +1,78 @@
1
+ /** The relay's tool contract this SDK was written against (flovia_status.contract_version). */
2
+ export declare const EXPECTED_CONTRACT_VERSION = "2026-10-07.116";
3
+ /** Every public explanation.code (the relay's contract). A provider's own HTTP error is `http_<status>`. */
4
+ export declare const PUBLIC_CODES: readonly ["key_disabled", "key_stopped", "consent_missing", "data_consent_mismatch", "data_consent_needed", "notice_first", "endpoint_unknown", "endpoint_disabled", "read_only", "not_relayed", "key_in_query", "secret_in_input", "invalid_body", "body_too_large", "answer_too_large", "answer_precision_unsafe", "answer_not_relayed", "request_text_too_long", "over_max_credits", "cost_confirmation_needed", "window_5h", "window_week", "over_week", "free_limited", "free_needs_email", "global_cap", "flovia_paused", "free_paused", "rate_limited", "too_many_in_parallel", "reservation_changed", "operation_conflict", "operation_pending", "upstream_unknown", "upstream_unavailable", "replay_response_unavailable", "rpc_error", "unknown_lookup", "invalid_cursor", "since_too_old", "since_in_future", "feedback_too_long", "feedback_limit", "not_configured", "service_off"];
5
+ export type FloviaCode = (typeof PUBLIC_CODES)[number] | `http_${number}`;
6
+ /** Whether the request ran upstream: "unknown" means keep the same operation_id and never send a replacement. */
7
+ export type Executed = "yes" | "no" | "unknown";
8
+ /** A refusal's explanation, as every tool returns it with ok: false. */
9
+ export interface Explanation {
10
+ by: string;
11
+ code: FloviaCode;
12
+ executed: Executed;
13
+ meaning: string;
14
+ next_step: string;
15
+ audience?: "agent" | "user";
16
+ }
17
+ /** Any tool reply. Fields beyond these depend on the tool; read them from the relay's documentation. */
18
+ export interface ToolReply {
19
+ ok?: boolean;
20
+ explanation?: Explanation;
21
+ retry_after_seconds?: number;
22
+ resets_at?: string;
23
+ [field: string]: unknown;
24
+ }
25
+ export interface Window {
26
+ used_pct: number;
27
+ resets_at: string | null;
28
+ }
29
+ export type StatusArgs = Record<string, never>;
30
+ export interface StatusReply extends ToolReply {
31
+ contract_version: string;
32
+ stage: string;
33
+ five_hour: Window;
34
+ week: Window;
35
+ services_off: string[];
36
+ }
37
+ export interface UsageTodayArgs {
38
+ /** ISO 8601 time, at most 7 days ago. */
39
+ since?: string;
40
+ /** The previous next_cursor, unchanged, with the same since and conversation. */
41
+ cursor?: string;
42
+ /** Only this conversation's lookups. */
43
+ conversation?: string;
44
+ }
45
+ export interface UsageTodayReply extends ToolReply {
46
+ has_more?: boolean;
47
+ next_cursor?: string | null;
48
+ }
49
+ /** POST /openai/v1/responses: an OpenAI Responses request, text only. Unknown fields are refused. */
50
+ export interface GenerationRequest {
51
+ model: "gpt-6-luna";
52
+ input: string;
53
+ max_output_tokens?: number;
54
+ reasoning?: {
55
+ effort: "none" | "low";
56
+ };
57
+ stream?: false;
58
+ store?: false;
59
+ service_tier?: "default";
60
+ text?: {
61
+ format: {
62
+ type: "json_object";
63
+ };
64
+ };
65
+ }
66
+ export interface Generation {
67
+ /** The OpenAI Response object (public fields only). */
68
+ response: Record<string, unknown> & {
69
+ output?: unknown[];
70
+ };
71
+ /** The output_text parts joined, for convenience. */
72
+ outputText: string;
73
+ generationId: string | null;
74
+ /** true: the saved answer for this operation_id came back; nothing was generated or charged again. */
75
+ replayed: boolean;
76
+ receivedAt: string | null;
77
+ conversationId: string | null;
78
+ }
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ /** The relay's tool contract this SDK was written against (flovia_status.contract_version). */
2
+ export const EXPECTED_CONTRACT_VERSION = "2026-10-07.116";
3
+ /** Every public explanation.code (the relay's contract). A provider's own HTTP error is `http_<status>`. */
4
+ export const PUBLIC_CODES = [
5
+ "key_disabled", "key_stopped", "consent_missing", "data_consent_mismatch", "data_consent_needed", "notice_first",
6
+ "endpoint_unknown", "endpoint_disabled", "read_only", "not_relayed", "key_in_query", "secret_in_input", "invalid_body", "body_too_large",
7
+ "answer_too_large", "answer_precision_unsafe", "answer_not_relayed", "request_text_too_long",
8
+ "over_max_credits", "cost_confirmation_needed", "window_5h", "window_week", "over_week", "free_limited", "free_needs_email", "global_cap",
9
+ "flovia_paused", "free_paused", "rate_limited", "too_many_in_parallel", "reservation_changed",
10
+ "operation_conflict", "operation_pending", "upstream_unknown", "upstream_unavailable", "replay_response_unavailable", "rpc_error",
11
+ "unknown_lookup", "invalid_cursor", "since_too_old", "since_in_future", "feedback_too_long", "feedback_limit", "not_configured", "service_off",
12
+ ];
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@flovialabs/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "TypeScript client and CLI for Flovia: MCP tools over HTTP and OpenAI-compatible generation. Zero dependencies.",
6
+ "license": "MIT",
7
+ "homepage": "https://api.flovia.dev",
8
+ "keywords": [
9
+ "flovia",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "api",
13
+ "client",
14
+ "sdk",
15
+ "cli",
16
+ "openai"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "bin": {
27
+ "flovia": "./dist/cli.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "sideEffects": false,
39
+ "scripts": {
40
+ "typecheck": "tsc --noEmit -p tsconfig.json",
41
+ "lint": "oxlint --deny-warnings src test",
42
+ "test": "bun test",
43
+ "check": "bun run typecheck && bun run lint && bun run test",
44
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
45
+ "prepack": "bun run build"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }