routstrd 0.4.9 → 0.4.11
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/README.md +67 -19
- package/SECURITY.md +0 -1
- package/SKILL.md +242 -52
- package/dist/daemon/index.js +8553 -13913
- package/dist/index.js +31602 -36860
- package/package.json +3 -2
- package/src/daemon/http/index.ts +5 -11
- package/src/daemon/http/request-body.ts +67 -0
- package/src/daemon/models.ts +13 -10
- package/src/daemon/wallet/coco-client.ts +20 -5
- package/src/daemon/wallet/trusted-mints.ts +87 -0
- package/src/integrations/pi.ts +137 -32
- package/src/integrations/registry.ts +14 -0
- package/src/tui/usage/app.ts +16 -2
- package/src/tui/usage/data.ts +110 -0
- package/src/tui/usage/render.ts +199 -35
- package/src/TUI refactor.md +0 -113
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "routstrd",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"module": "src/index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"monitor": "bun src/index.ts monitor",
|
|
24
24
|
"lint": "tsc --noEmit",
|
|
25
25
|
"test": "bun test",
|
|
26
|
+
"smoke": "scripts/smoke/chat-completions.sh",
|
|
26
27
|
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js --external better-sqlite3 && bun build src/daemon/index.ts --target=bun --outfile=dist/daemon/index.js --external better-sqlite3",
|
|
27
28
|
"build:binary": "bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig src/index.ts --outfile=dist/routstrd",
|
|
28
29
|
"prepublishOnly": "bun run build"
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"@cashu/cashu-ts": "^4.3.0",
|
|
39
40
|
"@cashu/coco-core": "^1.0.1",
|
|
40
41
|
"@cashu/coco-sqlite-bun": "^1.0.1",
|
|
41
|
-
"@routstr/sdk": "^0.4.
|
|
42
|
+
"@routstr/sdk": "^0.4.6",
|
|
42
43
|
"@scure/bip39": "^2.2.0",
|
|
43
44
|
"applesauce-core": "^5.1.0",
|
|
44
45
|
"applesauce-relay": "^5.1.0",
|
package/src/daemon/http/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { receiveCashuToken } from "../wallet";
|
|
22
22
|
import { getClientsFromStore } from "../../utils/clients";
|
|
23
23
|
import { getUsageSummary } from "./usage-summary";
|
|
24
|
+
import { applyDefaultOutputTokenLimit } from "./request-body";
|
|
24
25
|
|
|
25
26
|
// Hop-by-hop headers describe the *upstream* connection, not this one, and must
|
|
26
27
|
// never be copied onto our response. In particular, copying the upstream's
|
|
@@ -1747,17 +1748,10 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1747
1748
|
// limit. Without this, the SDK prices at the provider's worst-case
|
|
1748
1749
|
// max_completion_cost, which varies widely across providers (2.3× for
|
|
1749
1750
|
// kimi-k3) and balloons during provider failover. Chat/completions use
|
|
1750
|
-
// max_tokens; the OpenAI Responses API uses max_output_tokens.
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
if (typeof bodyObj.max_output_tokens !== "number") {
|
|
1755
|
-
bodyObj.max_output_tokens = deps.maxTokens;
|
|
1756
|
-
}
|
|
1757
|
-
} else if (typeof bodyObj.max_tokens !== "number") {
|
|
1758
|
-
bodyObj.max_tokens = deps.maxTokens;
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1751
|
+
// max_tokens; the OpenAI Responses API uses max_output_tokens. Endpoints
|
|
1752
|
+
// that do not define those fields (e.g. /v1/systemone) are forwarded
|
|
1753
|
+
// verbatim — injecting one makes a strict upstream answer 400.
|
|
1754
|
+
applyDefaultOutputTokenLimit(url.pathname, bodyObj, deps.maxTokens);
|
|
1761
1755
|
|
|
1762
1756
|
const forcedProvider: string | undefined =
|
|
1763
1757
|
url.searchParams.get("provider") ||
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-body shaping for the proxied path.
|
|
3
|
+
*
|
|
4
|
+
* The ingress forwards the caller's JSON body to the upstream provider. It is
|
|
5
|
+
* allowed to add exactly one thing — a default output-token limit — and only
|
|
6
|
+
* where that field is part of the endpoint's documented vocabulary. Anything
|
|
7
|
+
* else must be forwarded verbatim: several upstreams (notably TypeSafe's
|
|
8
|
+
* `POST /v1/systemone`) validate strictly and reject unknown fields, so an
|
|
9
|
+
* injected chat-completions field turns a valid request into an opaque
|
|
10
|
+
* `400 Invalid request.`
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OpenAI-compatible endpoints whose bodies define `max_tokens`,
|
|
15
|
+
* `max_output_tokens`, and `stream`.
|
|
16
|
+
*
|
|
17
|
+
* Matched by path suffix so `/v1/chat/completions`, `/chat/completions`, and
|
|
18
|
+
* custom path-prefixed proxies all qualify. Kept in sync with
|
|
19
|
+
* `isOpenAiJsonBodyPath` in `@routstr/sdk` (exported there since 0.4.6); once
|
|
20
|
+
* routstrd depends on that version this local copy can be dropped.
|
|
21
|
+
*/
|
|
22
|
+
export const OPENAI_JSON_BODY_PATH_SUFFIXES = [
|
|
23
|
+
"/chat/completions",
|
|
24
|
+
"/completions",
|
|
25
|
+
"/responses",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when `pathname` addresses an endpoint whose body carries the OpenAI
|
|
30
|
+
* request vocabulary. Ignores a query string and a trailing slash.
|
|
31
|
+
*/
|
|
32
|
+
export function isOpenAiJsonBodyPath(pathname: string): boolean {
|
|
33
|
+
const path = (pathname.split("?")[0] ?? "").replace(/\/+$/, "");
|
|
34
|
+
return OPENAI_JSON_BODY_PATH_SUFFIXES.some((suffix) => path.endsWith(suffix));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cap the completion budget when the client does not set an output token
|
|
39
|
+
* limit. Without this, the SDK prices at the provider's worst-case
|
|
40
|
+
* `max_completion_cost`, which varies widely across providers (2.3× for
|
|
41
|
+
* kimi-k3) and balloons during provider failover. Chat/completions use
|
|
42
|
+
* `max_tokens`; the OpenAI Responses API uses `max_output_tokens`.
|
|
43
|
+
*
|
|
44
|
+
* Non-OpenAI endpoints are left untouched — `/v1/systemone` returns typed
|
|
45
|
+
* judgments rather than generated text, so it has no completion budget, and it
|
|
46
|
+
* rejects the field outright. Mutates `body` in place. Returns true when a
|
|
47
|
+
* field was added.
|
|
48
|
+
*/
|
|
49
|
+
export function applyDefaultOutputTokenLimit(
|
|
50
|
+
pathname: string,
|
|
51
|
+
body: Record<string, unknown>,
|
|
52
|
+
maxTokens: number,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (!(maxTokens > 0) || !isOpenAiJsonBodyPath(pathname)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (pathname.split("?")[0]!.replace(/\/+$/, "").endsWith("/responses")) {
|
|
59
|
+
if (typeof body.max_output_tokens === "number") return false;
|
|
60
|
+
body.max_output_tokens = maxTokens;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof body.max_tokens === "number") return false;
|
|
65
|
+
body.max_tokens = maxTokens;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
package/src/daemon/models.ts
CHANGED
|
@@ -87,22 +87,25 @@ export function createModelService(
|
|
|
87
87
|
const providers = await modelManager.bootstrapProviders(false);
|
|
88
88
|
logger.log(`Bootstrapped ${providers.length} providers`);
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
90
|
+
// Mirror discovery into the store so `providers list` reports the same
|
|
91
|
+
// set that the model manager polls and routes to. The list is
|
|
92
|
+
// *replaced*, not merged: an add-only merge keeps URLs discovery no
|
|
93
|
+
// longer reports (e.g. after a bootstrap regression or a provider
|
|
94
|
+
// unpublishing) visible to the CLI and to per-model views while
|
|
95
|
+
// routing silently ignores them.
|
|
92
96
|
const {
|
|
93
97
|
baseUrlsList,
|
|
94
98
|
setBaseUrlsList,
|
|
95
99
|
setDisabledProviders,
|
|
96
100
|
} = store.getState();
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
setBaseUrlsList(merged);
|
|
101
|
+
const known = new Set(baseUrlsList);
|
|
102
|
+
const inSync =
|
|
103
|
+
known.size === providers.length &&
|
|
104
|
+
providers.every((url) => known.has(url));
|
|
105
|
+
if (!inSync) {
|
|
106
|
+
setBaseUrlsList(providers);
|
|
104
107
|
logger.log(
|
|
105
|
-
`Synced ${
|
|
108
|
+
`Synced ${providers.length} discovered provider(s) into store (was ${baseUrlsList.length})`,
|
|
106
109
|
);
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -58,6 +58,9 @@ import {
|
|
|
58
58
|
walletDir as defaultWalletDir,
|
|
59
59
|
walletPidPath as defaultWalletPidPath,
|
|
60
60
|
} from "./paths";
|
|
61
|
+
import { DEFAULT_MINT_URL, seedTrustedMints } from "./trusted-mints";
|
|
62
|
+
|
|
63
|
+
export { DEFAULT_MINT_URL, DEFAULT_TRUSTED_MINT_URLS } from "./trusted-mints";
|
|
61
64
|
|
|
62
65
|
const NPC_DEFAULT_BASE_URL = "https://npubx.cash";
|
|
63
66
|
|
|
@@ -117,7 +120,6 @@ interface CocodConfig {
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
const STARTUP_LOG_PREFIX = "[routstrd:start]";
|
|
120
|
-
export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
|
|
121
123
|
|
|
122
124
|
function startupProgress(message: string): void {
|
|
123
125
|
logger.info(message);
|
|
@@ -1293,10 +1295,23 @@ export async function createCocoClient(
|
|
|
1293
1295
|
configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL,
|
|
1294
1296
|
);
|
|
1295
1297
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1298
|
+
// Seeds the mints we ship as trusted. The default mint is strict (see
|
|
1299
|
+
// seedTrustedMints); extra seeds only warn, so an unreachable mint that is
|
|
1300
|
+
// not the default cannot stop the daemon from starting.
|
|
1301
|
+
await seedTrustedMints(
|
|
1302
|
+
{
|
|
1303
|
+
trustedMints: trustedMints.map((mint) => mint.mintUrl),
|
|
1304
|
+
addMint: (mintUrl) => coco!.mint.addMint(mintUrl, { trusted: true }),
|
|
1305
|
+
},
|
|
1306
|
+
defaultMintUrl,
|
|
1307
|
+
{
|
|
1308
|
+
onProgress: startupProgress,
|
|
1309
|
+
onError: (message, error) =>
|
|
1310
|
+
logger.warn(message, {
|
|
1311
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1312
|
+
}),
|
|
1313
|
+
},
|
|
1314
|
+
);
|
|
1300
1315
|
|
|
1301
1316
|
// Persist only after the mint was successfully fetched and trusted. A failed
|
|
1302
1317
|
// network request must not leave config pointing at an unusable default.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { normalizeMintUrl } from "@cashu/coco-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mint used as the default for wallets that have no configured default. It is
|
|
5
|
+
* always trusted, and a wallet is never allowed to point its default at a mint
|
|
6
|
+
* it could not fetch.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mints routstrd trusts out of the box. Every entry is added as a trusted mint
|
|
12
|
+
* on startup so users can send/receive without an explicit
|
|
13
|
+
* `wallet mints add`. `DEFAULT_MINT_URL` is listed first because it seeds the
|
|
14
|
+
* default mint of a fresh wallet; extra entries never change an existing
|
|
15
|
+
* default.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_TRUSTED_MINT_URLS: readonly string[] = [
|
|
18
|
+
DEFAULT_MINT_URL,
|
|
19
|
+
"https://mint.minibits.cash/Bitcoin",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export interface TrustedMintSeeder {
|
|
23
|
+
/** Mint URLs the wallet currently trusts. */
|
|
24
|
+
trustedMints: readonly string[];
|
|
25
|
+
/** Trust a mint, fetching its info and keysets from the mint itself. */
|
|
26
|
+
addMint: (mintUrl: string) => Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SeedTrustedMintsOptions {
|
|
30
|
+
/** Mints to ensure are trusted, in order. Defaults to the shipped seeds. */
|
|
31
|
+
seeds?: readonly string[];
|
|
32
|
+
/** Called before each mint fetch with a user-facing progress message. */
|
|
33
|
+
onProgress?: (message: string) => void;
|
|
34
|
+
/** Called when a non-default seed could not be added. */
|
|
35
|
+
onError?: (message: string, error: unknown) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Stored and configured mint URLs come from SQLite and JSON, so a malformed
|
|
39
|
+
// value must never crash startup. Normalization only strips the default port
|
|
40
|
+
// and a trailing slash, so falling back to the raw string keeps comparisons
|
|
41
|
+
// meaningful.
|
|
42
|
+
function safeNormalizeMintUrl(mintUrl: string): string {
|
|
43
|
+
try {
|
|
44
|
+
return normalizeMintUrl(mintUrl);
|
|
45
|
+
} catch {
|
|
46
|
+
return mintUrl;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ensure the mint seeds routstrd ships are trusted, without ever moving a
|
|
52
|
+
* wallet's default away from `defaultMintUrl`.
|
|
53
|
+
*
|
|
54
|
+
* The default mint is seeded strictly: if it cannot be fetched the error is
|
|
55
|
+
* rethrown, because persisting an unusable default is worse than failing
|
|
56
|
+
* startup. Every other seed is best-effort — sending the mint fetch failure to
|
|
57
|
+
* `onError` — so a single unreachable mint cannot keep the daemon down.
|
|
58
|
+
*/
|
|
59
|
+
export async function seedTrustedMints(
|
|
60
|
+
wallet: TrustedMintSeeder,
|
|
61
|
+
defaultMintUrl: string,
|
|
62
|
+
options: SeedTrustedMintsOptions = {},
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const seeds = options.seeds ?? DEFAULT_TRUSTED_MINT_URLS;
|
|
65
|
+
const target = safeNormalizeMintUrl(defaultMintUrl);
|
|
66
|
+
const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl));
|
|
67
|
+
const attempted = new Set<string>();
|
|
68
|
+
|
|
69
|
+
for (const seed of [defaultMintUrl, ...seeds]) {
|
|
70
|
+
const mintUrl = safeNormalizeMintUrl(seed);
|
|
71
|
+
if (attempted.has(mintUrl)) continue;
|
|
72
|
+
attempted.add(mintUrl);
|
|
73
|
+
if (trusted.has(mintUrl)) continue;
|
|
74
|
+
|
|
75
|
+
const isDefault = mintUrl === target;
|
|
76
|
+
try {
|
|
77
|
+
options.onProgress?.(
|
|
78
|
+
`Adding ${isDefault ? "default" : "trusted"} mint: ${mintUrl}`,
|
|
79
|
+
);
|
|
80
|
+
await wallet.addMint(mintUrl);
|
|
81
|
+
trusted.add(mintUrl);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (isDefault) throw error;
|
|
84
|
+
options.onError?.(`Could not add trusted mint ${mintUrl}`, error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/integrations/pi.ts
CHANGED
|
@@ -5,17 +5,144 @@ import type { RoutstrdConfig } from "../utils/config";
|
|
|
5
5
|
import type { IntegrationConfig, RoutstrModel } from "./registry";
|
|
6
6
|
import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
|
|
7
7
|
|
|
8
|
-
type
|
|
8
|
+
export type PiThinkingLevel =
|
|
9
|
+
| "off"
|
|
10
|
+
| "minimal"
|
|
11
|
+
| "low"
|
|
12
|
+
| "medium"
|
|
13
|
+
| "high"
|
|
14
|
+
| "xhigh"
|
|
15
|
+
| "max";
|
|
16
|
+
|
|
17
|
+
export type ThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
|
|
18
|
+
|
|
19
|
+
export type PiModelEntry = {
|
|
9
20
|
id: string;
|
|
10
21
|
contextWindow?: number;
|
|
11
22
|
name?: string;
|
|
12
23
|
input?: string[];
|
|
13
|
-
// Thinking/reasoning config is user-curated and preserved across refreshes.
|
|
14
24
|
reasoning?: boolean;
|
|
15
|
-
thinkingLevelMap?:
|
|
25
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
16
26
|
compat?: Record<string, unknown>;
|
|
17
27
|
};
|
|
18
28
|
|
|
29
|
+
/** pi thinking levels, in pi's documented order. */
|
|
30
|
+
export const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [
|
|
31
|
+
"off",
|
|
32
|
+
"minimal",
|
|
33
|
+
"low",
|
|
34
|
+
"medium",
|
|
35
|
+
"high",
|
|
36
|
+
"xhigh",
|
|
37
|
+
"max",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* pi level -> the value sent to the provider. routstr-core publishes its
|
|
42
|
+
* allowlist in this same vocabulary (`none`/`minimal`/`low`/`medium`/`high`/
|
|
43
|
+
* `xhigh`/`max`), so the mapping is identity apart from `off`.
|
|
44
|
+
*/
|
|
45
|
+
const THINKING_LEVEL_VALUES: Record<PiThinkingLevel, string> = {
|
|
46
|
+
off: "none",
|
|
47
|
+
minimal: "minimal",
|
|
48
|
+
low: "low",
|
|
49
|
+
medium: "medium",
|
|
50
|
+
high: "high",
|
|
51
|
+
xhigh: "xhigh",
|
|
52
|
+
max: "max",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build pi's thinking fields from the daemon's per-model `reasoning` object.
|
|
57
|
+
*
|
|
58
|
+
* Every level is written explicitly: pi treats an omitted level as "use the
|
|
59
|
+
* provider's default mapping" for standard levels (through `high`) and as
|
|
60
|
+
* "unsupported" for `xhigh`/`max`, so a partial map would silently advertise
|
|
61
|
+
* levels the model rejects. `null` hides the level in pi's UI.
|
|
62
|
+
*
|
|
63
|
+
* Returns null when the daemon publishes no effort allowlist (models whose
|
|
64
|
+
* upstream only reports `mandatory`, or that report no reasoning at all).
|
|
65
|
+
* Those are not guessable, so the caller keeps whatever the user curated.
|
|
66
|
+
*/
|
|
67
|
+
export function deriveThinkingFields(
|
|
68
|
+
model: RoutstrModel,
|
|
69
|
+
): { reasoning: true; thinkingLevelMap: ThinkingLevelMap } | null {
|
|
70
|
+
const reasoning = model.reasoning;
|
|
71
|
+
if (!reasoning) return null;
|
|
72
|
+
|
|
73
|
+
const supported = (reasoning.supported_efforts ?? [])
|
|
74
|
+
.filter((effort): effort is string => typeof effort === "string")
|
|
75
|
+
.map((effort) => effort.trim().toLowerCase())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
if (supported.length === 0) return null;
|
|
78
|
+
|
|
79
|
+
const allowed = new Set(supported);
|
|
80
|
+
// routstr-core strips `none` from mandatory models, so never offer `off` there.
|
|
81
|
+
if (reasoning.mandatory === true) allowed.delete("none");
|
|
82
|
+
|
|
83
|
+
const thinkingLevelMap: ThinkingLevelMap = {};
|
|
84
|
+
for (const level of PI_THINKING_LEVELS) {
|
|
85
|
+
const value = THINKING_LEVEL_VALUES[level];
|
|
86
|
+
thinkingLevelMap[level] = allowed.has(value) ? value : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { reasoning: true, thinkingLevelMap };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek");
|
|
93
|
+
|
|
94
|
+
/** Project one daemon model onto a pi config entry. */
|
|
95
|
+
export function buildPiModelEntry(
|
|
96
|
+
model: RoutstrModel,
|
|
97
|
+
previous?: PiModelEntry,
|
|
98
|
+
): PiModelEntry {
|
|
99
|
+
const entry: PiModelEntry = { id: model.id };
|
|
100
|
+
|
|
101
|
+
if (model.context_length !== undefined && model.context_length > 0) {
|
|
102
|
+
entry.contextWindow = model.context_length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (model.name) {
|
|
106
|
+
entry.name = model.name;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Map the daemon's input modalities to Pi's ["text", "image"] vocabulary.
|
|
110
|
+
const mods = model.architecture?.input_modalities ?? [];
|
|
111
|
+
const input: string[] = [];
|
|
112
|
+
if (mods.includes("text")) input.push("text");
|
|
113
|
+
if (mods.includes("image")) input.push("image");
|
|
114
|
+
entry.input = input;
|
|
115
|
+
|
|
116
|
+
const derived = deriveThinkingFields(model);
|
|
117
|
+
if (derived) {
|
|
118
|
+
entry.reasoning = derived.reasoning;
|
|
119
|
+
entry.thinkingLevelMap = derived.thinkingLevelMap;
|
|
120
|
+
} else {
|
|
121
|
+
// No allowlist to derive from: keep the user's hand-curated fields rather
|
|
122
|
+
// than guessing which levels the model accepts.
|
|
123
|
+
if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning;
|
|
124
|
+
if (previous?.thinkingLevelMap !== undefined) {
|
|
125
|
+
entry.thinkingLevelMap = previous.thinkingLevelMap;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// `compat` is never published by the daemon; it stays user-curated — except
|
|
130
|
+
// for deepseek* models, where the role spelling below is authoritative.
|
|
131
|
+
if (isDeepSeekModel(model.id)) {
|
|
132
|
+
// DeepSeek-backed models reject the `developer` role (OpenAI's newer
|
|
133
|
+
// spelling of `system`) on strict upstreams with a hard 400. Pi sends
|
|
134
|
+
// `developer` for reasoning models on unrecognized providers because its
|
|
135
|
+
// provider heuristics only see the local daemon URL and can't know
|
|
136
|
+
// DeepSeek sits behind it — force the universally-accepted `system`
|
|
137
|
+
// spelling for every deepseek* model, keeping any other user-set keys.
|
|
138
|
+
entry.compat = { ...(previous?.compat ?? {}), supportsDeveloperRole: false };
|
|
139
|
+
} else if (previous?.compat !== undefined) {
|
|
140
|
+
entry.compat = previous.compat;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
|
|
19
146
|
type PiProviderConfig = {
|
|
20
147
|
baseUrl?: string;
|
|
21
148
|
api?: string;
|
|
@@ -68,39 +195,17 @@ export async function installPiIntegration(
|
|
|
68
195
|
}
|
|
69
196
|
|
|
70
197
|
// Rebuild every model entry from scratch from the daemon, so the generated
|
|
71
|
-
// models.json is always a faithful projection of the daemon's state.
|
|
72
|
-
//
|
|
73
|
-
//
|
|
198
|
+
// models.json is always a faithful projection of the daemon's state.
|
|
199
|
+
// Thinking fields are derived from the model's published reasoning allowlist;
|
|
200
|
+
// when the daemon has none, the user's hand-curated values are preserved.
|
|
201
|
+
// `compat` stays user-curated, except for the deepseek* pin applied below.
|
|
74
202
|
const existingModels = new Map<string, PiModelEntry>(
|
|
75
203
|
(piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
|
|
76
204
|
);
|
|
77
205
|
|
|
78
|
-
const providerModels: PiModelEntry[] = models.map((model) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (model.context_length !== undefined && model.context_length > 0) {
|
|
83
|
-
entry.contextWindow = model.context_length;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (model.name) {
|
|
87
|
-
entry.name = model.name;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Map the daemon's input modalities to Pi's ["text", "image"] vocabulary.
|
|
91
|
-
const mods = model.architecture?.input_modalities ?? [];
|
|
92
|
-
const input: string[] = [];
|
|
93
|
-
if (mods.includes("text")) input.push("text");
|
|
94
|
-
if (mods.includes("image")) input.push("image");
|
|
95
|
-
entry.input = input;
|
|
96
|
-
|
|
97
|
-
// Preserve user-curated thinking fields from the previous entry.
|
|
98
|
-
if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning;
|
|
99
|
-
if (previous?.thinkingLevelMap !== undefined) entry.thinkingLevelMap = previous.thinkingLevelMap;
|
|
100
|
-
if (previous?.compat !== undefined) entry.compat = previous.compat;
|
|
101
|
-
|
|
102
|
-
return entry;
|
|
103
|
-
});
|
|
206
|
+
const providerModels: PiModelEntry[] = models.map((model) =>
|
|
207
|
+
buildPiModelEntry(model, existingModels.get(model.id)),
|
|
208
|
+
);
|
|
104
209
|
|
|
105
210
|
// Rebuild provider from scratch too; only write routstrd-managed fields.
|
|
106
211
|
piConfig.providers["routstr"] = {
|
|
@@ -14,6 +14,19 @@ export interface IntegrationConfig {
|
|
|
14
14
|
configPath: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Per-model reasoning metadata as published by routstr-core (OpenRouter shape).
|
|
19
|
+
* Models with no reasoning support omit the whole object, and models whose
|
|
20
|
+
* upstream publishes no effort allowlist carry only `mandatory`.
|
|
21
|
+
*/
|
|
22
|
+
export type RoutstrReasoning = {
|
|
23
|
+
mandatory?: boolean | null;
|
|
24
|
+
default_enabled?: boolean | null;
|
|
25
|
+
supported_efforts?: string[] | null;
|
|
26
|
+
default_effort?: string | null;
|
|
27
|
+
supports_max_tokens?: boolean | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
17
30
|
export type RoutstrModel = {
|
|
18
31
|
id: string;
|
|
19
32
|
name?: string;
|
|
@@ -27,6 +40,7 @@ export type RoutstrModel = {
|
|
|
27
40
|
context_length?: number;
|
|
28
41
|
max_completion_tokens?: number;
|
|
29
42
|
};
|
|
43
|
+
reasoning?: RoutstrReasoning | null;
|
|
30
44
|
};
|
|
31
45
|
|
|
32
46
|
export type IntegrationFn = (
|
package/src/tui/usage/app.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getVisibleTabs } from "./constants.ts";
|
|
2
2
|
import type { Tab } from "./types.ts";
|
|
3
|
-
import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
|
|
3
|
+
import { buildClientNaming, emptyClientNaming, fetchBalance, fetchClients, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type ClientNaming, type NpubEntry, type StatusInfo } from "./data.ts";
|
|
4
4
|
import {
|
|
5
5
|
applyScrollToContent,
|
|
6
6
|
exitSearchMode,
|
|
@@ -48,6 +48,8 @@ export async function runUsageTui(): Promise<void> {
|
|
|
48
48
|
let balance: BalanceInfo | null = null;
|
|
49
49
|
let status: StatusInfo | null = null;
|
|
50
50
|
let clients: ClientInfo[] = [];
|
|
51
|
+
let npubs: NpubEntry[] = [];
|
|
52
|
+
let naming: ClientNaming = emptyClientNaming();
|
|
51
53
|
let visibleTabs: Tab[] = getVisibleTabs(false);
|
|
52
54
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
53
55
|
let autoRefresh = true;
|
|
@@ -144,6 +146,18 @@ export async function runUsageTui(): Promise<void> {
|
|
|
144
146
|
currentTab = "clients";
|
|
145
147
|
vimState.scrollPos = 0;
|
|
146
148
|
}
|
|
149
|
+
|
|
150
|
+
// Names/roles live on the auth proxy, not the usage summary. Only
|
|
151
|
+
// fetch them when the Npubs tab is actually reachable, and keep the
|
|
152
|
+
// last good list if the endpoint is temporarily unreachable.
|
|
153
|
+
if (npubsVisible) {
|
|
154
|
+
const newNpubs = await fetchNpubs();
|
|
155
|
+
if (newNpubs.length > 0) npubs = newNpubs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Owner info (for "Name (client-id)" labels in the Recent tab) comes
|
|
159
|
+
// from /clients + /npubs; both are empty in local mode.
|
|
160
|
+
naming = buildClientNaming(clients, npubs);
|
|
147
161
|
}
|
|
148
162
|
render();
|
|
149
163
|
} finally {
|
|
@@ -170,7 +184,7 @@ export async function runUsageTui(): Promise<void> {
|
|
|
170
184
|
return;
|
|
171
185
|
}
|
|
172
186
|
|
|
173
|
-
const content = renderTabContent(currentTab, stats, balance, status, width,
|
|
187
|
+
const content = renderTabContent(currentTab, stats, balance, status, width, naming);
|
|
174
188
|
const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
|
|
175
189
|
const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
176
190
|
const chromeLines = chrome.split("\n").length - 1;
|