routstrd 0.4.10 → 0.4.12
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 +55 -15
- package/SECURITY.md +0 -1
- package/SKILL.md +267 -52
- package/dist/daemon/index.js +93747 -70861
- package/dist/index.js +65958 -42561
- package/package.json +3 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +323 -38
- package/src/daemon/http/request-body.ts +67 -0
- package/src/daemon/http/request-path.ts +39 -0
- package/src/daemon/index.ts +1 -1
- package/src/daemon/models.ts +46 -11
- package/src/daemon/wallet/auto-refill.ts +20 -8
- package/src/daemon/wallet/cleanup.ts +16 -2
- package/src/daemon/wallet/coco-client.ts +941 -115
- package/src/daemon/wallet/index.ts +168 -50
- package/src/daemon/wallet/mint-quote-recovery.ts +119 -0
- package/src/daemon/wallet/recovery-probe.ts +312 -0
- package/src/daemon/wallet/recovery-work.ts +45 -0
- package/src/daemon/wallet/testing/fake-mint.ts +347 -0
- package/src/daemon/wallet/trusted-mints.ts +87 -0
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +189 -34
- package/src/integrations/registry.ts +14 -0
- package/src/tui/usage/render.ts +161 -22
- package/src/utils/config.ts +3 -2
- package/src/utils/cooldowns.ts +134 -0
- package/src/utils/daemon-client.ts +53 -9
- package/src/utils/history.ts +9 -0
- package/src/utils/with-timeout.ts +21 -0
- package/src/TUI refactor.md +0 -113
- package/src/daemon/wallet/cocod-client.ts +0 -505
|
@@ -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.minibits.cash/Bitcoin";
|
|
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.cubabitcoin.org",
|
|
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
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { HistoryEntry } from "@cashu/coco-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract shared by every wallet implementation.
|
|
5
|
+
*
|
|
6
|
+
* The wallet runs in-process (`createCocoClient` in `./coco-client`); this
|
|
7
|
+
* module only describes what the rest of routstrd is allowed to ask of it.
|
|
8
|
+
* Historically this lived in a `cocod-client` module that also shipped an
|
|
9
|
+
* HTTP/unix-socket client for the external `cocod` binary. That client was
|
|
10
|
+
* removed once the in-process wallet replaced it — see `./migration` and the
|
|
11
|
+
* `legacyCocod*` path helpers in `./paths` for the migration-only code that
|
|
12
|
+
* still knows about external cocod installs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type WalletRuntimeState =
|
|
16
|
+
| "UNINITIALIZED"
|
|
17
|
+
| "LOCKED"
|
|
18
|
+
| "UNLOCKED"
|
|
19
|
+
| "RECOVERING"
|
|
20
|
+
| "ERROR";
|
|
21
|
+
|
|
22
|
+
/** Live progress for background wallet recovery started at daemon startup. */
|
|
23
|
+
export interface WalletRecoveryProgress {
|
|
24
|
+
state: "RECOVERING" | "UNLOCKED" | "ERROR";
|
|
25
|
+
/** Current recovery phase, e.g. "Mint recovery" or "done". */
|
|
26
|
+
phase: string;
|
|
27
|
+
pendingSends: number;
|
|
28
|
+
inflightProofs: number;
|
|
29
|
+
pendingMints: number;
|
|
30
|
+
/** Expired unpaid mint quotes failed locally without a mint round-trip. */
|
|
31
|
+
failedMintQuotes: number;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** NPC (npubx.cash) Lightning address details for this wallet. */
|
|
36
|
+
export interface NpcAddress {
|
|
37
|
+
/** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */
|
|
38
|
+
address: string;
|
|
39
|
+
/** NPC username, when one has been claimed. */
|
|
40
|
+
name?: string;
|
|
41
|
+
/** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */
|
|
42
|
+
pubkey?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Result of an NPC username claim attempt. */
|
|
46
|
+
export interface NpcUsernameResult {
|
|
47
|
+
success: boolean;
|
|
48
|
+
/** Present when NPC requires payment to claim the username. */
|
|
49
|
+
paymentRequest?: {
|
|
50
|
+
amount?: number;
|
|
51
|
+
mints?: string[];
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for the wallet cleanup command. */
|
|
57
|
+
export interface WalletCleanupOptions {
|
|
58
|
+
/** Only clean up operations for this mint URL. */
|
|
59
|
+
mintUrl?: string;
|
|
60
|
+
/** Minimum operation age in milliseconds (defaults to 7 days / 1 week). */
|
|
61
|
+
minAgeMs?: number;
|
|
62
|
+
/** Report what would be cleaned without applying changes. */
|
|
63
|
+
dryRun?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Fail expired mint quotes without confirming UNPAID with the mint. Only for
|
|
66
|
+
* operators who accept the risk of stranding a quote that was paid before
|
|
67
|
+
* its invoice expired; recovery is the safe default.
|
|
68
|
+
*/
|
|
69
|
+
force?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Summary of a wallet cleanup run. */
|
|
73
|
+
export interface WalletCleanupResult {
|
|
74
|
+
dryRun: boolean;
|
|
75
|
+
/** Expired quotes selected for checking; dry runs do not contact the mint. */
|
|
76
|
+
mintQuoteCandidates: number;
|
|
77
|
+
/** Number actually marked failed (always zero in a dry run). */
|
|
78
|
+
failedMintQuotes: number;
|
|
79
|
+
/** Expired quotes kept pending because they are paid/issued or unverified. */
|
|
80
|
+
leftForRecovery: number;
|
|
81
|
+
/** Number of stale pending send operations reclaimed. */
|
|
82
|
+
reclaimedSends: number;
|
|
83
|
+
/** Number of stale prepared melt operations cancelled. */
|
|
84
|
+
cancelledMelts: number;
|
|
85
|
+
/** Number of in-flight operations that were left untouched. */
|
|
86
|
+
skipped: number;
|
|
87
|
+
errors: Array<{ operationId: string; error: string }>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class WalletHttpError extends Error {
|
|
91
|
+
status: number;
|
|
92
|
+
|
|
93
|
+
constructor(status: number, message: string) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "WalletHttpError";
|
|
96
|
+
this.status = status;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Progress of a Lightning top-up created with `receiveBolt11`. */
|
|
101
|
+
export interface MintQuoteStatus {
|
|
102
|
+
operationId: string;
|
|
103
|
+
state: "pending" | "executing" | "finalized" | "failed";
|
|
104
|
+
/** Last quote state reported by the mint (UNPAID, PAID, ISSUED). */
|
|
105
|
+
mintState?: string;
|
|
106
|
+
amount: number;
|
|
107
|
+
mintUrl: string;
|
|
108
|
+
error?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Options for explicit PAID mint-quote recovery. */
|
|
112
|
+
export interface WalletMintQuoteRecoveryOptions {
|
|
113
|
+
/** Target only these operation ids (may include failed operations). */
|
|
114
|
+
operationIds?: string[];
|
|
115
|
+
/** Re-open failed operations instead of skipping them. */
|
|
116
|
+
includeFailed?: boolean;
|
|
117
|
+
/** Per-quote mint timeout in milliseconds. */
|
|
118
|
+
timeoutMs?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Summary of a PAID mint-quote recovery run. */
|
|
122
|
+
export interface WalletMintQuoteRecoveryResult {
|
|
123
|
+
/** Operations whose quote state was checked with the mint. */
|
|
124
|
+
checked: number;
|
|
125
|
+
/** Operations whose paid sats were minted or restored. */
|
|
126
|
+
recovered: number;
|
|
127
|
+
/** Quotes the mint still reports UNPAID; left pending. */
|
|
128
|
+
waiting: number;
|
|
129
|
+
/** Quotes the mint can no longer issue. */
|
|
130
|
+
terminal: number;
|
|
131
|
+
/** Failed operations moved back to pending before checking. */
|
|
132
|
+
reopened: number;
|
|
133
|
+
/** Operations left to a later run (mint unreachable, budget spent, non-terminal). */
|
|
134
|
+
retryable: number;
|
|
135
|
+
/** Operations skipped because an earlier recovery of them is still running. */
|
|
136
|
+
busy: number;
|
|
137
|
+
errors: Array<{ operationId: string; error: string }>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Summary of a stuck-operation (send/melt/mint) recovery run. */
|
|
141
|
+
export interface WalletStuckOperationRecoveryResult {
|
|
142
|
+
/** Timed-out waits; the underlying operation remains tracked. */
|
|
143
|
+
timedOut: number;
|
|
144
|
+
/** Operations for which recovery was attempted (not necessarily completed). */
|
|
145
|
+
attempted: number;
|
|
146
|
+
/** Locked operations or unfinished work from another pass; retry later. */
|
|
147
|
+
busy: number;
|
|
148
|
+
/** Operations skipped for unreachable mints, shutdown, or pass budget exhaustion. */
|
|
149
|
+
skipped: number;
|
|
150
|
+
/** Operations at reachable mints whose recovery still failed. */
|
|
151
|
+
failed: number;
|
|
152
|
+
/** Unreachable mint URL -> number of operations skipped there. */
|
|
153
|
+
skippedMints: Record<string, number>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface WalletClient {
|
|
157
|
+
ping(): Promise<boolean>;
|
|
158
|
+
getStatus(): Promise<WalletRuntimeState>;
|
|
159
|
+
unlock(passphrase: string): Promise<string>;
|
|
160
|
+
getBalances(): Promise<Record<string, number>>;
|
|
161
|
+
receiveCashu(token: string): Promise<string>;
|
|
162
|
+
receiveBolt11(
|
|
163
|
+
amount: number,
|
|
164
|
+
mintUrl?: string,
|
|
165
|
+
): Promise<{ invoice: string; operationId?: string }>;
|
|
166
|
+
/** Progress of a top-up, when the wallet tracks mint operations. */
|
|
167
|
+
getMintQuote?(operationId: string): Promise<MintQuoteStatus | null>;
|
|
168
|
+
sendCashu(amount: number, mintUrl?: string): Promise<string>;
|
|
169
|
+
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
|
|
170
|
+
listMints(): Promise<string[]>;
|
|
171
|
+
addMint(url: string): Promise<string>;
|
|
172
|
+
getMintInfo(url: string): Promise<unknown>;
|
|
173
|
+
getDefaultMint(): Promise<string | null>;
|
|
174
|
+
setDefaultMint(url: string): Promise<string>;
|
|
175
|
+
/** Release resources held by in-process wallet implementations. */
|
|
176
|
+
dispose?(): Promise<void>;
|
|
177
|
+
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
|
|
178
|
+
/** Look up a single transaction by its history entry ID. */
|
|
179
|
+
getHistoryEntryById(id: string): Promise<HistoryEntry | null>;
|
|
180
|
+
/** NPC (npubx.cash) Lightning address for this wallet. */
|
|
181
|
+
getNpcAddress(): Promise<NpcAddress>;
|
|
182
|
+
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
|
|
183
|
+
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
|
|
184
|
+
/** Manually trigger an NPC quote sync into the wallet. */
|
|
185
|
+
syncNpc(): Promise<void>;
|
|
186
|
+
/** Clear stuck pending/in-flight wallet operations that are safe to resolve. */
|
|
187
|
+
cleanupStuckOperations?(
|
|
188
|
+
options?: WalletCleanupOptions,
|
|
189
|
+
): Promise<WalletCleanupResult>;
|
|
190
|
+
/**
|
|
191
|
+
* Re-issue PAID mint quotes whose sats were never claimed, optionally
|
|
192
|
+
* targeting specific operations (including ones coco already failed).
|
|
193
|
+
*/
|
|
194
|
+
recoverMintQuotes?(
|
|
195
|
+
options?: WalletMintQuoteRecoveryOptions,
|
|
196
|
+
onProgress?: (message: string) => void,
|
|
197
|
+
): Promise<WalletMintQuoteRecoveryResult>;
|
|
198
|
+
/**
|
|
199
|
+
* Recover stuck send/melt/mint operations whose mints answer a
|
|
200
|
+
* reachability probe. Operations a live execute holds are reported busy,
|
|
201
|
+
* never driven. Receive stays startup-only (receive dedup classification).
|
|
202
|
+
*/
|
|
203
|
+
recoverStuckOperations?(): Promise<WalletStuckOperationRecoveryResult>;
|
|
204
|
+
/** Report background wallet recovery progress, when the wallet supports it. */
|
|
205
|
+
getRecoveryProgress?(): Promise<WalletRecoveryProgress>;
|
|
206
|
+
}
|
package/src/integrations/pi.ts
CHANGED
|
@@ -5,17 +5,173 @@ 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;
|
|
21
|
+
api?: string;
|
|
10
22
|
contextWindow?: number;
|
|
11
23
|
name?: string;
|
|
12
24
|
input?: string[];
|
|
13
|
-
// Thinking/reasoning config is user-curated and preserved across refreshes.
|
|
14
25
|
reasoning?: boolean;
|
|
15
|
-
thinkingLevelMap?:
|
|
26
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
16
27
|
compat?: Record<string, unknown>;
|
|
17
28
|
};
|
|
18
29
|
|
|
30
|
+
/** pi thinking levels, in pi's documented order. */
|
|
31
|
+
export const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [
|
|
32
|
+
"off",
|
|
33
|
+
"minimal",
|
|
34
|
+
"low",
|
|
35
|
+
"medium",
|
|
36
|
+
"high",
|
|
37
|
+
"xhigh",
|
|
38
|
+
"max",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* pi level -> the value sent to the provider. routstr-core publishes its
|
|
43
|
+
* allowlist in this same vocabulary (`none`/`minimal`/`low`/`medium`/`high`/
|
|
44
|
+
* `xhigh`/`max`), so the mapping is identity apart from `off`.
|
|
45
|
+
*/
|
|
46
|
+
const THINKING_LEVEL_VALUES: Record<PiThinkingLevel, string> = {
|
|
47
|
+
off: "none",
|
|
48
|
+
minimal: "minimal",
|
|
49
|
+
low: "low",
|
|
50
|
+
medium: "medium",
|
|
51
|
+
high: "high",
|
|
52
|
+
xhigh: "xhigh",
|
|
53
|
+
max: "max",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build pi's thinking fields from the daemon's per-model `reasoning` object.
|
|
58
|
+
*
|
|
59
|
+
* Every level is written explicitly: pi treats an omitted level as "use the
|
|
60
|
+
* provider's default mapping" for standard levels (through `high`) and as
|
|
61
|
+
* "unsupported" for `xhigh`/`max`, so a partial map would silently advertise
|
|
62
|
+
* levels the model rejects. `null` hides the level in pi's UI.
|
|
63
|
+
*
|
|
64
|
+
* Returns null when the daemon publishes no effort allowlist (models whose
|
|
65
|
+
* upstream only reports `mandatory`, or that report no reasoning at all).
|
|
66
|
+
* Those are not guessable, so the caller keeps whatever the user curated.
|
|
67
|
+
*/
|
|
68
|
+
export function deriveThinkingFields(
|
|
69
|
+
model: RoutstrModel,
|
|
70
|
+
): { reasoning: true; thinkingLevelMap: ThinkingLevelMap } | null {
|
|
71
|
+
const reasoning = model.reasoning;
|
|
72
|
+
if (!reasoning) return null;
|
|
73
|
+
|
|
74
|
+
const supported = (reasoning.supported_efforts ?? [])
|
|
75
|
+
.filter((effort): effort is string => typeof effort === "string")
|
|
76
|
+
.map((effort) => effort.trim().toLowerCase())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
if (supported.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
const allowed = new Set(supported);
|
|
81
|
+
// routstr-core strips `none` from mandatory models, so never offer `off` there.
|
|
82
|
+
if (reasoning.mandatory === true) allowed.delete("none");
|
|
83
|
+
|
|
84
|
+
const thinkingLevelMap: ThinkingLevelMap = {};
|
|
85
|
+
for (const level of PI_THINKING_LEVELS) {
|
|
86
|
+
const value = THINKING_LEVEL_VALUES[level];
|
|
87
|
+
thinkingLevelMap[level] = allowed.has(value) ? value : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { reasoning: true, thinkingLevelMap };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek");
|
|
94
|
+
const isGptModel = (id: string): boolean => id.startsWith("gpt-");
|
|
95
|
+
/**
|
|
96
|
+
* Anthropic-served models (claude-opus-5.5, claude-sonnet-5, claude-fable-5.1,
|
|
97
|
+
* ...). routstr nodes proxy the Anthropic-native `messages` route, so pi's
|
|
98
|
+
* Anthropic transport can talk to the node's own endpoint instead of the
|
|
99
|
+
* OpenAI-shaped translation.
|
|
100
|
+
*/
|
|
101
|
+
const isClaudeModel = (id: string): boolean => id.startsWith("claude");
|
|
102
|
+
|
|
103
|
+
/** Project one daemon model onto a pi config entry. */
|
|
104
|
+
export function buildPiModelEntry(
|
|
105
|
+
model: RoutstrModel,
|
|
106
|
+
previous?: PiModelEntry,
|
|
107
|
+
): PiModelEntry {
|
|
108
|
+
const entry: PiModelEntry = { id: model.id };
|
|
109
|
+
|
|
110
|
+
if (model.context_length !== undefined && model.context_length > 0) {
|
|
111
|
+
entry.contextWindow = model.context_length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (model.name) {
|
|
115
|
+
entry.name = model.name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Map the daemon's input modalities to Pi's ["text", "image"] vocabulary.
|
|
119
|
+
const mods = model.architecture?.input_modalities ?? [];
|
|
120
|
+
const input: string[] = [];
|
|
121
|
+
if (mods.includes("text")) input.push("text");
|
|
122
|
+
if (mods.includes("image")) input.push("image");
|
|
123
|
+
entry.input = input;
|
|
124
|
+
|
|
125
|
+
// Per-model transport pins. `api` is per-model while the provider `baseUrl`
|
|
126
|
+
// is shared, and the transports disagree about what a base URL means: the
|
|
127
|
+
// OpenAI SDKs append only their endpoint (`/chat/completions`, `/responses`)
|
|
128
|
+
// while Anthropic SDKs append `/v1/messages` themselves. The provider base
|
|
129
|
+
// URL is therefore the daemon ROOT (see installPiIntegration), which is the
|
|
130
|
+
// one spelling that resolves correctly for every transport:
|
|
131
|
+
// gpt-* -> {root}/responses -> `responses`
|
|
132
|
+
// claude* -> {root}/v1/messages -> `messages`
|
|
133
|
+
// other -> {root}/chat/completions -> `chat/completions`
|
|
134
|
+
// Pins override a curated value: a stale `anthropic-messages` left on a
|
|
135
|
+
// non-claude model picks an endpoint the daemon is not expecting, and the
|
|
136
|
+
// user cannot see from models.json which family needs which transport.
|
|
137
|
+
if (isGptModel(model.id)) {
|
|
138
|
+
entry.api = "openai-responses";
|
|
139
|
+
} else if (isClaudeModel(model.id)) {
|
|
140
|
+
entry.api = "anthropic-messages";
|
|
141
|
+
} else if (previous?.api !== undefined) {
|
|
142
|
+
entry.api = previous.api;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const derived = deriveThinkingFields(model);
|
|
146
|
+
if (derived) {
|
|
147
|
+
entry.reasoning = derived.reasoning;
|
|
148
|
+
entry.thinkingLevelMap = derived.thinkingLevelMap;
|
|
149
|
+
} else {
|
|
150
|
+
// No allowlist to derive from: keep the user's hand-curated fields rather
|
|
151
|
+
// than guessing which levels the model accepts.
|
|
152
|
+
if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning;
|
|
153
|
+
if (previous?.thinkingLevelMap !== undefined) {
|
|
154
|
+
entry.thinkingLevelMap = previous.thinkingLevelMap;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// `compat` is never published by the daemon; it stays user-curated — except
|
|
159
|
+
// for deepseek* models, where the role spelling below is authoritative.
|
|
160
|
+
if (isDeepSeekModel(model.id)) {
|
|
161
|
+
// DeepSeek-backed models reject the `developer` role (OpenAI's newer
|
|
162
|
+
// spelling of `system`) on strict upstreams with a hard 400. Pi sends
|
|
163
|
+
// `developer` for reasoning models on unrecognized providers because its
|
|
164
|
+
// provider heuristics only see the local daemon URL and can't know
|
|
165
|
+
// DeepSeek sits behind it — force the universally-accepted `system`
|
|
166
|
+
// spelling for every deepseek* model, keeping any other user-set keys.
|
|
167
|
+
entry.compat = { ...(previous?.compat ?? {}), supportsDeveloperRole: false };
|
|
168
|
+
} else if (previous?.compat !== undefined) {
|
|
169
|
+
entry.compat = previous.compat;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return entry;
|
|
173
|
+
}
|
|
174
|
+
|
|
19
175
|
type PiProviderConfig = {
|
|
20
176
|
baseUrl?: string;
|
|
21
177
|
api?: string;
|
|
@@ -27,17 +183,36 @@ type PiConfig = {
|
|
|
27
183
|
providers?: Record<string, PiProviderConfig>;
|
|
28
184
|
};
|
|
29
185
|
|
|
186
|
+
export type PiIntegrationDeps = {
|
|
187
|
+
callDaemon: typeof callDaemon;
|
|
188
|
+
getDaemonBaseUrl: typeof getDaemonBaseUrl;
|
|
189
|
+
};
|
|
190
|
+
|
|
30
191
|
export async function installPiIntegration(
|
|
31
192
|
config: RoutstrdConfig,
|
|
32
193
|
apiKey: string,
|
|
33
194
|
integrationConfig: IntegrationConfig,
|
|
195
|
+
// Injectable I/O so tests don't need mock.module, whose overrides leak
|
|
196
|
+
// across test files for the rest of the run under bun's runner.
|
|
197
|
+
deps: Partial<PiIntegrationDeps> = {},
|
|
34
198
|
): Promise<void> {
|
|
35
199
|
const { name, configPath } = integrationConfig;
|
|
200
|
+
const callDaemonFn = deps.callDaemon ?? callDaemon;
|
|
201
|
+
const getDaemonBaseUrlFn = deps.getDaemonBaseUrl ?? getDaemonBaseUrl;
|
|
36
202
|
|
|
37
203
|
console.log("\nInstalling routstr models in pi models.json...");
|
|
38
204
|
console.log(`Using API key for ${name}`);
|
|
39
205
|
|
|
40
|
-
|
|
206
|
+
// The daemon ROOT, deliberately without `/v1`. Every transport appends its
|
|
207
|
+
// own endpoint path, and only the Anthropic ones add a version prefix
|
|
208
|
+
// (`/v1/messages`); a base URL that already carries `/v1` therefore yields
|
|
209
|
+
// the doubled `/v1/v1/messages`, which routstr-core rejects with a 404 from
|
|
210
|
+
// every provider in the pool (it canonicalizes exactly one optional `v1/`).
|
|
211
|
+
// At the root, openai-completions -> `/chat/completions`, openai-responses
|
|
212
|
+
// -> `/responses` and anthropic-messages -> `/v1/messages` all land on an
|
|
213
|
+
// allowed route. getDaemonBaseUrl() strips any trailing slash, so no path
|
|
214
|
+
// can be double-slashed either.
|
|
215
|
+
const baseUrl = getDaemonBaseUrlFn(config);
|
|
41
216
|
|
|
42
217
|
let piConfig: PiConfig = {};
|
|
43
218
|
|
|
@@ -59,7 +234,7 @@ export async function installPiIntegration(
|
|
|
59
234
|
// Ensure directory exists
|
|
60
235
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
61
236
|
|
|
62
|
-
const data = await
|
|
237
|
+
const data = await callDaemonFn("/models");
|
|
63
238
|
const models = (data.output as { models: RoutstrModel[] } | undefined)?.models || [];
|
|
64
239
|
|
|
65
240
|
if (models.length === 0) {
|
|
@@ -68,39 +243,19 @@ export async function installPiIntegration(
|
|
|
68
243
|
}
|
|
69
244
|
|
|
70
245
|
// 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
|
-
//
|
|
246
|
+
// models.json is always a faithful projection of the daemon's state.
|
|
247
|
+
// Thinking fields are derived from the model's published reasoning allowlist;
|
|
248
|
+
// when the daemon has none, the user's hand-curated values are preserved.
|
|
249
|
+
// `compat` stays user-curated, except for the deepseek* pin applied below;
|
|
250
|
+
// `api` is pinned per family (see buildPiModelEntry), since the family
|
|
251
|
+
// decides which transport — and so which endpoint — the model is served by.
|
|
74
252
|
const existingModels = new Map<string, PiModelEntry>(
|
|
75
253
|
(piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
|
|
76
254
|
);
|
|
77
255
|
|
|
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
|
-
});
|
|
256
|
+
const providerModels: PiModelEntry[] = models.map((model) =>
|
|
257
|
+
buildPiModelEntry(model, existingModels.get(model.id)),
|
|
258
|
+
);
|
|
104
259
|
|
|
105
260
|
// Rebuild provider from scratch too; only write routstrd-managed fields.
|
|
106
261
|
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 = (
|