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.
@@ -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
+ }
@@ -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 PiModelEntry = {
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?: Record<string, string | null>;
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
- const baseUrl = `${getDaemonBaseUrl(config)}/v1`;
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 callDaemon("/models");
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. The only
72
- // exception is thinking/reasoning config (reasoning, thinkingLevelMap, compat),
73
- // which the daemon does not provide and the user curates by hand — preserve it.
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
- const previous = existingModels.get(model.id);
80
- const entry: PiModelEntry = { id: model.id };
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 = (