@gtrabanco/pi-nan-provider 0.2.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/AGENTS.md +84 -0
- package/CLAUDE.md +1 -0
- package/CONTRIBUTING.md +48 -0
- package/LICENSE +21 -0
- package/README.es.md +144 -0
- package/README.md +144 -0
- package/package.json +59 -0
- package/scripts/generate-models.ts +239 -0
- package/scripts/models.generated.ts +440 -0
- package/src/fetch-models.ts +254 -0
- package/src/index.ts +96 -0
- package/src/mcp/nan-media.ts +199 -0
- package/src/mcp/nan-search.ts +220 -0
- package/src/mcp/stdio-client.ts +222 -0
- package/src/provider-factory.ts +96 -0
- package/src/providers.ts +34 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge to NaN's official remote MCP server over plain HTTP — no MCP client
|
|
3
|
+
* needed in pi (pi intentionally ships without MCP support; see docs/usage.md).
|
|
4
|
+
*
|
|
5
|
+
* Endpoint (NaN OpenAPI spec, tag "MCP"): `https://api.nan.builders/mcp`
|
|
6
|
+
* (host root, NOT under `/v1`). Transport is streamable HTTP and stateless;
|
|
7
|
+
* protocol is JSON-RPC 2.0 (`initialize`, `tools/list`, `tools/call`, `ping`).
|
|
8
|
+
* Auth is the same `sk-` key as the REST API (`Authorization: Bearer`), and
|
|
9
|
+
* MCP calls share the key's rate limit, daily quota, and concurrency.
|
|
10
|
+
*
|
|
11
|
+
* Today the server exposes `web_search` (same arguments as `POST /v1/search`);
|
|
12
|
+
* it is a growing registry, so this module keeps a generic `callNanMcpTool`
|
|
13
|
+
* that can invoke any current or future tool by name.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Type, type Static } from "@earendil-works/pi-ai";
|
|
17
|
+
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
/** NaN's official remote MCP endpoint (host root, not /v1). */
|
|
20
|
+
export const NAN_MCP_URL = "https://api.nan.builders/mcp";
|
|
21
|
+
/** Web search can be slow; the endpoint shares the key's rate limits. */
|
|
22
|
+
export const NAN_MCP_TIMEOUT_MS = 30_000;
|
|
23
|
+
/** Env var that disables MCP tool registration: NAN_MCP_TOOLS=0|false|off. */
|
|
24
|
+
export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
|
|
25
|
+
|
|
26
|
+
const NAN_PROVIDER_ID = "nan";
|
|
27
|
+
export const NAN_API_KEY_ENV = "NAN_API_KEY";
|
|
28
|
+
|
|
29
|
+
export interface NapiKeyContext {
|
|
30
|
+
/** pi's model registry (checks the stored credential and env vars). */
|
|
31
|
+
modelRegistry?: { getApiKeyForProvider(provider: string): Promise<string | undefined> };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the NaN API key for tool execution: pi's registry first (it covers
|
|
36
|
+
* the stored auth.json credential and configured env sources), then a direct
|
|
37
|
+
* env fallback so the tools also work on pi versions without the registry.
|
|
38
|
+
* Never logs or embeds the key beyond the Authorization header.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveNanApiKey(ctx: NapiKeyContext): Promise<string | undefined> {
|
|
41
|
+
try {
|
|
42
|
+
const stored = await ctx.modelRegistry?.getApiKeyForProvider(NAN_PROVIDER_ID);
|
|
43
|
+
if (stored) return stored;
|
|
44
|
+
} catch {
|
|
45
|
+
// Registry unavailable (older pi) or provider not registered — env fallback below.
|
|
46
|
+
}
|
|
47
|
+
return process.env[NAN_API_KEY_ENV] || undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NanMcpToolCall {
|
|
51
|
+
/** MCP tool name, e.g. "web_search". */
|
|
52
|
+
name: string;
|
|
53
|
+
/** Tool arguments object (JSON-serializable). */
|
|
54
|
+
arguments?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NanMcpToolResult {
|
|
58
|
+
/** true when the call completed without a tool-level or transport error. */
|
|
59
|
+
ok: boolean;
|
|
60
|
+
/** Concatenated text content returned by the tool. */
|
|
61
|
+
text: string;
|
|
62
|
+
/** Error description when ok is false. */
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface NanMcpCallOptions {
|
|
67
|
+
apiKey?: string;
|
|
68
|
+
timeoutMs?: number;
|
|
69
|
+
fetchImpl?: typeof fetch;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderMcpContent(content: unknown): string {
|
|
73
|
+
if (!Array.isArray(content)) return "";
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
for (const item of content) {
|
|
76
|
+
if (!item || typeof item !== "object") continue;
|
|
77
|
+
const block = item as { type?: string; text?: string };
|
|
78
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
79
|
+
parts.push(block.text);
|
|
80
|
+
} else if (block.type === "image") {
|
|
81
|
+
parts.push("[image returned by tool; see the saved file path in the text above if present]");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return parts.join("\n\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Invoke a tool on NaN's official MCP server: POST a JSON-RPC 2.0
|
|
89
|
+
* `tools/call` and flatten the response content. Transport failures,
|
|
90
|
+
* JSON-RPC errors, and tool errors all surface as `{ ok: false }` with a
|
|
91
|
+
* message — never as a thrown error, so tool results degrade gracefully.
|
|
92
|
+
*/
|
|
93
|
+
export async function callNanMcpTool(
|
|
94
|
+
name: string,
|
|
95
|
+
args: Record<string, unknown> | undefined,
|
|
96
|
+
options: NanMcpCallOptions = {},
|
|
97
|
+
): Promise<NanMcpToolResult> {
|
|
98
|
+
const { apiKey, timeoutMs = NAN_MCP_TIMEOUT_MS, fetchImpl = fetch } = options;
|
|
99
|
+
if (!apiKey) return { ok: false, text: "", error: `${NAN_API_KEY_ENV} is not set — set it or run /login nan.` };
|
|
100
|
+
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetchImpl(NAN_MCP_URL, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${apiKey}`,
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
Accept: "application/json",
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
jsonrpc: "2.0",
|
|
113
|
+
id: 1,
|
|
114
|
+
method: "tools/call",
|
|
115
|
+
params: { name, arguments: args ?? {} },
|
|
116
|
+
}),
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
text: "",
|
|
123
|
+
error: `NaN MCP returned HTTP ${response.status}${response.status === 401 ? " — check your NaN API key" : ""}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const payload = (await response.json()) as {
|
|
127
|
+
result?: { content?: unknown; isError?: boolean };
|
|
128
|
+
error?: { code?: number; message?: string };
|
|
129
|
+
};
|
|
130
|
+
if (payload.error) {
|
|
131
|
+
return { ok: false, text: "", error: `NaN MCP error ${payload.error.code ?? ""}: ${payload.error.message ?? "unknown"}` };
|
|
132
|
+
}
|
|
133
|
+
const text = renderMcpContent(payload.result?.content);
|
|
134
|
+
if (payload.result?.isError) {
|
|
135
|
+
return { ok: false, text, error: text || "NaN MCP tool reported an error" };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, text: text || "(empty response)" };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const aborted = controller.signal.aborted;
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
text: "",
|
|
143
|
+
error: aborted
|
|
144
|
+
? `NaN MCP call timed out after ${timeoutMs}ms`
|
|
145
|
+
: `NaN MCP call failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
146
|
+
};
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timeoutId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const webSearchParameters = Type.Object({
|
|
153
|
+
query: Type.String({ description: "The search query." }),
|
|
154
|
+
count: Type.Optional(
|
|
155
|
+
Type.Integer({
|
|
156
|
+
description: "Number of results to return, 1-20 (values outside the range are clamped). Default 5.",
|
|
157
|
+
minimum: 1,
|
|
158
|
+
maximum: 20,
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
freshness: Type.Optional(
|
|
162
|
+
Type.String({
|
|
163
|
+
description:
|
|
164
|
+
"Recency filter: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or a 'YYYY-MM-DDtoYYYY-MM-DD' date range. Omit for no time filter.",
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
fetch_content: Type.Optional(
|
|
168
|
+
Type.Boolean({
|
|
169
|
+
description:
|
|
170
|
+
"When true, also fetch and include the readable main text of the top results. Slower; defaults to snippets only.",
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
export type NanWebSearchParams = Static<typeof webSearchParameters>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* pi tool bridging NaN's official MCP `web_search`. Registered by default
|
|
179
|
+
* when the runtime supports registerTool; disable with NAN_MCP_TOOLS=0.
|
|
180
|
+
*
|
|
181
|
+
* Failure convention per pi's AgentTool contract: execute() throws on
|
|
182
|
+
* failure instead of encoding errors in content — the runtime surfaces the
|
|
183
|
+
* error to the model so it can self-correct (e.g. ask the user for a key).
|
|
184
|
+
*/
|
|
185
|
+
export function createNanWebSearchTool(): ToolDefinition<typeof webSearchParameters> {
|
|
186
|
+
return {
|
|
187
|
+
name: "nan_web_search",
|
|
188
|
+
label: "NaN Web Search",
|
|
189
|
+
description:
|
|
190
|
+
"Web search via NaN's remote MCP server (api.nan.builders/mcp). Returns ranked web results with snippets; requires NAN_API_KEY. Same rate limit and quota as the REST /v1/search endpoint.",
|
|
191
|
+
promptSnippet: "nan_web_search(query, count?, freshness?, fetch_content?): web search via the NaN MCP server",
|
|
192
|
+
parameters: webSearchParameters,
|
|
193
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
194
|
+
const apiKey = await resolveNanApiKey(contextHasKeySource(ctx));
|
|
195
|
+
if (!apiKey) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`${NAN_API_KEY_ENV} is not set. Export it (export ${NAN_API_KEY_ENV}="sk-...") or run /login nan.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const result = await callNanMcpTool(
|
|
201
|
+
"web_search",
|
|
202
|
+
params as Record<string, unknown>,
|
|
203
|
+
{ apiKey, timeoutMs: NAN_MCP_TIMEOUT_MS },
|
|
204
|
+
);
|
|
205
|
+
if (!result.ok) throw new Error(result.error ?? "NaN MCP call failed");
|
|
206
|
+
return { content: [{ type: "text", text: result.text }], details: undefined } as const;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Whether MCP tool registration is disabled via env (NAN_MCP_TOOLS=0|false|off). */
|
|
212
|
+
export function mcpToolsDisabled(): boolean {
|
|
213
|
+
const value = process.env[NAN_MCP_TOOLS_ENV]?.trim().toLowerCase();
|
|
214
|
+
return value === "0" || value === "false" || value === "off";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Extract an ExtensionContext-shaped key source for tests without full pi. */
|
|
218
|
+
export function contextHasKeySource(ctx: ExtensionContext): NapiKeyContext {
|
|
219
|
+
return ctx as unknown as NapiKeyContext;
|
|
220
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP (Model Context Protocol) stdio client — just enough protocol to
|
|
3
|
+
* call one tool per spawned process, so bridging stdio MCP servers is lazy by
|
|
4
|
+
* construction: nothing starts, connects, or costs anything until a tool is
|
|
5
|
+
* actually invoked.
|
|
6
|
+
*
|
|
7
|
+
* Protocol: JSON-RPC 2.0 over the child's stdin/stdout, one message per line
|
|
8
|
+
* (MCP stdio transport). Handshake: `initialize` request → `initialized`
|
|
9
|
+
* notification → request. The child process is terminated after the call, so
|
|
10
|
+
* each invocation pays a fresh spawn (~npx overhead on first use, cached
|
|
11
|
+
* afterwards) in exchange for zero idle cost and no daemon management.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
/** Oldest stable protocol version; accepted by every MCP SDK server. */
|
|
17
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
18
|
+
const CLIENT_INFO = { name: "pi-nan-provider", version: "0.2.0" };
|
|
19
|
+
|
|
20
|
+
export interface StdioMcpCallOptions {
|
|
21
|
+
/** Command to spawn, e.g. ["npx", "-y", "nan-mcp-server@1.0.7"]. */
|
|
22
|
+
command: readonly string[];
|
|
23
|
+
/** Extra environment for the child (merged over process.env). */
|
|
24
|
+
env?: Record<string, string | undefined>;
|
|
25
|
+
/** Kill the child and fail the call after this long. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Cooperative cancellation from the host tool call. */
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
/** Working directory for the child. */
|
|
30
|
+
cwd?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StdioMcpToolResult {
|
|
34
|
+
/** true when the tool call completed without a tool-level or transport error. */
|
|
35
|
+
ok: boolean;
|
|
36
|
+
/** Concatenated text content returned by the tool. */
|
|
37
|
+
text: string;
|
|
38
|
+
/** Error description when ok is false. */
|
|
39
|
+
error?: string;
|
|
40
|
+
/** Server stderr tail, attached to failures for diagnosis. */
|
|
41
|
+
stderrTail?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface JsonRpcMessage {
|
|
45
|
+
jsonrpc?: string;
|
|
46
|
+
id?: number | string | null;
|
|
47
|
+
method?: string;
|
|
48
|
+
params?: unknown;
|
|
49
|
+
result?: {
|
|
50
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
51
|
+
isError?: boolean;
|
|
52
|
+
tools?: unknown[];
|
|
53
|
+
};
|
|
54
|
+
error?: { code?: number; message?: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderContent(result: JsonRpcMessage["result"]): string {
|
|
58
|
+
const parts: string[] = [];
|
|
59
|
+
for (const item of result?.content ?? []) {
|
|
60
|
+
if (item?.type === "text" && typeof item.text === "string") {
|
|
61
|
+
parts.push(item.text);
|
|
62
|
+
} else if (item?.type === "image") {
|
|
63
|
+
parts.push("[image returned by tool]");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return parts.join("\n\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Spawn the MCP server, run the handshake, call one tool, and terminate the
|
|
71
|
+
* process. Never throws — every failure mode resolves to
|
|
72
|
+
* `{ ok: false, error }` so callers can decide how to surface it.
|
|
73
|
+
*/
|
|
74
|
+
export async function callStdioMcpTool(
|
|
75
|
+
toolName: string,
|
|
76
|
+
toolArguments: Record<string, unknown> | undefined,
|
|
77
|
+
options: StdioMcpCallOptions,
|
|
78
|
+
): Promise<StdioMcpToolResult> {
|
|
79
|
+
const { command, env, timeoutMs = 120_000, signal, cwd } = options;
|
|
80
|
+
|
|
81
|
+
if (command.length === 0) {
|
|
82
|
+
return { ok: false, text: "", error: "No MCP server command configured" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return new Promise<StdioMcpToolResult>((resolve) => {
|
|
86
|
+
let child: ReturnType<typeof spawn>;
|
|
87
|
+
try {
|
|
88
|
+
child = spawn(command[0]!, command.slice(1), {
|
|
89
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
90
|
+
env: { ...process.env, ...env },
|
|
91
|
+
...(cwd ? { cwd } : {}),
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
resolve({
|
|
95
|
+
ok: false,
|
|
96
|
+
text: "",
|
|
97
|
+
error: `Failed to start MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let settled = false;
|
|
103
|
+
let stdoutBuffer = "";
|
|
104
|
+
let stderr = "";
|
|
105
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
+
let lastResponse: JsonRpcMessage | undefined;
|
|
107
|
+
|
|
108
|
+
const cleanup = () => {
|
|
109
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
110
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
111
|
+
try {
|
|
112
|
+
child.stdin?.end();
|
|
113
|
+
} catch {
|
|
114
|
+
// stdin already closed
|
|
115
|
+
}
|
|
116
|
+
if (!child.killed) {
|
|
117
|
+
child.kill("SIGTERM");
|
|
118
|
+
}
|
|
119
|
+
// Escalate if the server ignores SIGTERM.
|
|
120
|
+
const escalate = setTimeout(() => child.kill("SIGKILL"), 1_000);
|
|
121
|
+
child.once("exit", () => clearTimeout(escalate));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const finish = (result: StdioMcpToolResult) => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
settled = true;
|
|
127
|
+
cleanup();
|
|
128
|
+
resolve(result);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const onAbort = () => finish({ ok: false, text: "", error: "MCP tool call aborted" });
|
|
132
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
133
|
+
|
|
134
|
+
const fail = (error: string): void => {
|
|
135
|
+
finish({
|
|
136
|
+
ok: false,
|
|
137
|
+
text: "",
|
|
138
|
+
error,
|
|
139
|
+
...(stderr ? { stderrTail: stderr.split("\n").slice(-5).join("\n").slice(-500) } : {}),
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
timeoutId = setTimeout(() => {
|
|
144
|
+
fail(`MCP server did not answer within ${timeoutMs}ms`);
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
|
|
147
|
+
const send = (message: object) => {
|
|
148
|
+
child.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
child.stdout?.setEncoding("utf8");
|
|
152
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
153
|
+
stdoutBuffer += chunk;
|
|
154
|
+
let newlineIndex = stdoutBuffer.indexOf("\n");
|
|
155
|
+
while (newlineIndex !== -1) {
|
|
156
|
+
const line = stdoutBuffer.slice(0, newlineIndex).trim();
|
|
157
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
158
|
+
if (line.length > 0) {
|
|
159
|
+
try {
|
|
160
|
+
handleLine(JSON.parse(line) as JsonRpcMessage);
|
|
161
|
+
} catch {
|
|
162
|
+
// Non-JSON stdout line — tolerate server banners/logs.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
newlineIndex = stdoutBuffer.indexOf("\n");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
child.stderr?.setEncoding("utf8");
|
|
170
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
171
|
+
stderr += chunk;
|
|
172
|
+
if (stderr.length > 8_000) stderr = stderr.slice(-4_000);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
child.once("error", (error) => {
|
|
176
|
+
fail(`MCP server process error: ${error instanceof Error ? error.message : String(error)}`);
|
|
177
|
+
});
|
|
178
|
+
child.once("exit", (code, exitSignal) => {
|
|
179
|
+
if (!settled) fail(`MCP server exited early (code=${code ?? "null"}, signal=${exitSignal ?? "null"})`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const handleLine = (message: JsonRpcMessage) => {
|
|
183
|
+
if (message.id === 1) {
|
|
184
|
+
// initialize response → complete handshake, then call the tool.
|
|
185
|
+
if (message.error) {
|
|
186
|
+
fail(`MCP handshake failed: ${message.error.message ?? "unknown error"}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
190
|
+
send({
|
|
191
|
+
jsonrpc: "2.0",
|
|
192
|
+
id: 2,
|
|
193
|
+
method: "tools/call",
|
|
194
|
+
params: { name: toolName, arguments: toolArguments ?? {} },
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (message.id === 2) {
|
|
199
|
+
lastResponse = message;
|
|
200
|
+
if (message.error) {
|
|
201
|
+
fail(`MCP tool error ${message.error.code ?? ""}: ${message.error.message ?? "unknown"}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const text = renderContent(message.result);
|
|
205
|
+
if (message.result?.isError) {
|
|
206
|
+
finish({ ok: false, text, error: text || "MCP tool reported an error" });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
finish({ ok: true, text: text || "(empty response)" });
|
|
210
|
+
}
|
|
211
|
+
// Other lines (notifications, keep-alives) are ignored.
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Handshake start.
|
|
215
|
+
send({
|
|
216
|
+
jsonrpc: "2.0",
|
|
217
|
+
id: 1,
|
|
218
|
+
method: "initialize",
|
|
219
|
+
params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO },
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared factory for OpenAI-compatible providers registered from this package.
|
|
3
|
+
*
|
|
4
|
+
* One implementation, N provider configs. A second provider-specific source
|
|
5
|
+
* file is a smell — add a config entry (see src/providers.ts) and, if needed,
|
|
6
|
+
* catalog data, never a parallel implementation.
|
|
7
|
+
*
|
|
8
|
+
* The resulting provider follows pi-ai's built-in provider shape (see
|
|
9
|
+
* `deepseekProvider()` in pi-ai): `createProvider` + `envApiKeyAuth` +
|
|
10
|
+
* `openAICompletionsApi`, with a `fetchModels` overlay that merges the live
|
|
11
|
+
* /models listing with the generated fallback catalog.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createProvider,
|
|
16
|
+
envApiKeyAuth,
|
|
17
|
+
type Provider,
|
|
18
|
+
type RefreshModelsContext,
|
|
19
|
+
} from "@earendil-works/pi-ai";
|
|
20
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
21
|
+
import {
|
|
22
|
+
baselineModels,
|
|
23
|
+
DEFAULT_MODELS_TIMEOUT_MS,
|
|
24
|
+
resolveCatalog,
|
|
25
|
+
type CatalogSource,
|
|
26
|
+
} from "./fetch-models.ts";
|
|
27
|
+
|
|
28
|
+
export interface OpenAICompatibleProviderConfig {
|
|
29
|
+
/** Provider id as registered in pi, e.g. "nan". */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Display name shown in /login and the model selector, e.g. "NaN". */
|
|
32
|
+
name: string;
|
|
33
|
+
/** OpenAI-compatible base URL including version path, e.g. "https://api.nan.builders/v1". */
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
/** Env vars consulted (in order) when no credential is stored, e.g. ["NAN_API_KEY"]. */
|
|
36
|
+
envVars: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NanCompatibleProviderOptions {
|
|
40
|
+
/** Timeout for the live /models fetch. Default: 3000ms. */
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a complete pi-ai Provider for an OpenAI-compatible endpoint:
|
|
48
|
+
*
|
|
49
|
+
* - auth: stored credential key wins, then the first set env var resolves;
|
|
50
|
+
* `/login <id>` prompts for the key (pi's `envApiKeyAuth` semantics — the
|
|
51
|
+
* same precedence the built-in providers use). No prompt is needed when the
|
|
52
|
+
* env var is set.
|
|
53
|
+
* - models: the generated fallback catalog as static baseline, so models are
|
|
54
|
+
* available with zero network.
|
|
55
|
+
* - fetchModels: live `/models` IDs × generated capability data; falls back
|
|
56
|
+
* to the baseline when the endpoint is unreachable. pi's Models runtime
|
|
57
|
+
* drives refreshes (startup/periodic) and persists the overlay.
|
|
58
|
+
* - api: `openAICompletionsApi()` (lazy-loaded streaming implementation).
|
|
59
|
+
*/
|
|
60
|
+
export function createNanCompatibleProvider(
|
|
61
|
+
config: OpenAICompatibleProviderConfig,
|
|
62
|
+
options: NanCompatibleProviderOptions = {},
|
|
63
|
+
): Provider<"openai-completions"> {
|
|
64
|
+
const source: CatalogSource = { providerId: config.id, baseUrl: config.baseUrl };
|
|
65
|
+
|
|
66
|
+
// Last successful live /models result, shared between fetchModels (writes)
|
|
67
|
+
// and filterModels (reads). When set it is authoritative for what your key
|
|
68
|
+
// can use — tier detection: NaN lists exactly the models your membership
|
|
69
|
+
// can call, so models absent from the live list are filtered out of
|
|
70
|
+
// `available` (e.g. premium-tier models you are not subscribed to).
|
|
71
|
+
let liveIds: Set<string> | undefined;
|
|
72
|
+
|
|
73
|
+
return createProvider({
|
|
74
|
+
id: config.id,
|
|
75
|
+
name: config.name,
|
|
76
|
+
baseUrl: config.baseUrl,
|
|
77
|
+
auth: { apiKey: envApiKeyAuth(`${config.name} API key`, config.envVars) },
|
|
78
|
+
models: baselineModels(source),
|
|
79
|
+
fetchModels: async (context: RefreshModelsContext) => {
|
|
80
|
+
const credential = context.credential;
|
|
81
|
+
const resolved = await resolveCatalog(source, {
|
|
82
|
+
apiKey: credential?.type === "api_key" ? credential.key : undefined,
|
|
83
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS,
|
|
84
|
+
fetchImpl: options.fetchImpl,
|
|
85
|
+
});
|
|
86
|
+
liveIds = resolved.liveIds;
|
|
87
|
+
return resolved.models;
|
|
88
|
+
},
|
|
89
|
+
filterModels: (models) => {
|
|
90
|
+
// Snapshot: TS can't prove `liveIds` unchanged across the closure boundary.
|
|
91
|
+
const current = liveIds;
|
|
92
|
+
return current ? models.filter((model) => current.has(model.id)) : models;
|
|
93
|
+
},
|
|
94
|
+
api: openAICompletionsApi(),
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry for this package. Each entry is registered through the
|
|
3
|
+
* shared factory in src/provider-factory.ts — adding a provider is one entry
|
|
4
|
+
* here (plus catalog data), never a second implementation file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OpenAICompatibleProviderConfig } from "./provider-factory.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* NaN Builders — https://nan.builders — community LiteLLM gateway.
|
|
11
|
+
*
|
|
12
|
+
* Base URL and env var confirmed by models.dev (provider "nan") and NaN's own
|
|
13
|
+
* getting-started docs; `openai-completions` + `supportsDeveloperRole: true`
|
|
14
|
+
* per NaN's published .pi/agent/models.json example.
|
|
15
|
+
*/
|
|
16
|
+
export const NAN_PROVIDER: OpenAICompatibleProviderConfig = {
|
|
17
|
+
id: "nan",
|
|
18
|
+
name: "NaN",
|
|
19
|
+
baseUrl: "https://api.nan.builders/v1",
|
|
20
|
+
envVars: ["NAN_API_KEY"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `helmcode` is intentionally NOT registered in v0.1.0. The founding prompt
|
|
25
|
+
* references it (shared factory + HELLMCODE_API_KEY), but no confirmed base
|
|
26
|
+
* URL or capability source exists — it is absent from models.dev and from
|
|
27
|
+
* NaN's docs, and fabricating either is against this repo's rules.
|
|
28
|
+
*
|
|
29
|
+
* When an endpoint and catalog source are confirmed: add an entry here (same
|
|
30
|
+
* shape as NAN_PROVIDER) and a models.dev/generator source if available. The
|
|
31
|
+
* shared factory covers it with zero new code — see the factory test that
|
|
32
|
+
* registers a second provider through the same code path.
|
|
33
|
+
*/
|
|
34
|
+
export const PROVIDERS: readonly OpenAICompatibleProviderConfig[] = [NAN_PROVIDER];
|