klyro 1.0.5 → 1.0.6
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/dist/agent/custom-agents.d.ts +3 -0
- package/dist/agent/custom-agents.js +96 -0
- package/dist/agent/orchestrator.d.ts +22 -2
- package/dist/agent/orchestrator.js +30 -4
- package/dist/agent/runtime.d.ts +5 -0
- package/dist/agent/runtime.js +164 -50
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +20 -0
- package/dist/cli/completion.js +2 -2
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/eval.d.ts +15 -1
- package/dist/cli/eval.js +34 -2
- package/dist/cli/hooks.d.ts +54 -5
- package/dist/cli/hooks.js +85 -6
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/repl.js +146 -25
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +92 -50
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +9 -1
- package/dist/cli/slash/parser.js +31 -9
- package/dist/context/memory.js +18 -1
- package/dist/eval/harness.d.ts +21 -3
- package/dist/eval/harness.js +31 -3
- package/dist/eval/judge.d.ts +32 -0
- package/dist/eval/judge.js +63 -0
- package/dist/eval/tasks.js +134 -0
- package/dist/index.js +150 -127
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +3 -1
- package/dist/mcp/config.js +19 -1
- package/dist/mcp/registry.d.ts +13 -0
- package/dist/mcp/registry.js +41 -1
- package/dist/mcp/remote.d.ts +29 -0
- package/dist/mcp/remote.js +153 -0
- package/dist/policy/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +13 -10
- package/dist/tui/app.js +140 -13
- package/dist/tui/app.test.js +24 -0
- package/dist/tui/approval.js +53 -1
- package/package.json +1 -1
package/dist/mcp/registry.js
CHANGED
|
@@ -30,6 +30,7 @@ import * as fs from 'node:fs';
|
|
|
30
30
|
import * as os from 'node:os';
|
|
31
31
|
import * as path from 'node:path';
|
|
32
32
|
import { McpClient, McpError } from './client.js';
|
|
33
|
+
import { RemoteMcpClient } from './remote.js';
|
|
33
34
|
import { loadMcpServers } from './config.js';
|
|
34
35
|
import { evaluateMcpPolicy } from './policy.js';
|
|
35
36
|
import { jsonSchemaToZod } from './schema.js';
|
|
@@ -141,9 +142,20 @@ async function executeMcpTool(server, spec, toolDef, client, input, ctx) {
|
|
|
141
142
|
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
|
|
142
143
|
}
|
|
143
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Construct the right client for a spec: remote HTTP when `url` is set,
|
|
147
|
+
* else stdio. Used by registration, probe, and prompt runs.
|
|
148
|
+
*/
|
|
149
|
+
export function makeMcpClient(name, spec) {
|
|
150
|
+
if (spec.url)
|
|
151
|
+
return new RemoteMcpClient(name, spec);
|
|
152
|
+
if (!spec.command)
|
|
153
|
+
throw new McpError(`mcp server "${name}" has neither command nor url`, 'INVALID_SPEC');
|
|
154
|
+
return new McpClient(name, spec);
|
|
155
|
+
}
|
|
144
156
|
export async function registerMcpServers(cfg, opts) {
|
|
145
157
|
const { registry, policy } = opts;
|
|
146
|
-
const factory = opts.clientFactory ??
|
|
158
|
+
const factory = opts.clientFactory ?? makeMcpClient;
|
|
147
159
|
const registered = [];
|
|
148
160
|
const errors = [];
|
|
149
161
|
const skipped = [];
|
|
@@ -291,3 +303,31 @@ export async function loadAndRegisterMcp(opts) {
|
|
|
291
303
|
};
|
|
292
304
|
}
|
|
293
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh stdio
|
|
308
|
+
* connection per invocation (no client lifecycle to manage), redacted
|
|
309
|
+
* output. Typing the prompt name is explicit consent, so project servers
|
|
310
|
+
* connect without the registration-time approval gate. Throws McpError /
|
|
311
|
+
* Error on unknown server, disabled server, or prompt failure.
|
|
312
|
+
*/
|
|
313
|
+
export async function runMcpPrompt(cwd, server, prompt, args) {
|
|
314
|
+
const cfg = loadMcpServers(cwd);
|
|
315
|
+
const spec = cfg.servers[server];
|
|
316
|
+
if (!spec)
|
|
317
|
+
throw new Error(`mcp server not found: ${server}`);
|
|
318
|
+
if (spec.disabled)
|
|
319
|
+
throw new Error(`mcp server disabled: ${server}`);
|
|
320
|
+
const client = makeMcpClient(server, spec);
|
|
321
|
+
if (!client.promptsGet)
|
|
322
|
+
throw new Error(`mcp server "${server}" does not support prompts`);
|
|
323
|
+
try {
|
|
324
|
+
const withConnect = client;
|
|
325
|
+
if (typeof withConnect.connect === 'function')
|
|
326
|
+
await withConnect.connect();
|
|
327
|
+
const text = await client.promptsGet(prompt, args);
|
|
328
|
+
return redact(text).slice(0, 8000);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await client.close().catch(() => undefined);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP transport — JSON-RPC 2.0 over HTTP POST (Streamable HTTP).
|
|
3
|
+
*
|
|
4
|
+
* Covers servers that speak plain request/response JSON-RPC at an HTTPS
|
|
5
|
+
* endpoint (the common self-hosted shape). Pure SSE-stream servers that
|
|
6
|
+
* require a persistent GET event stream are NOT supported — `probe` reports
|
|
7
|
+
* that explicitly. Custom headers (e.g. Authorization) come from the spec
|
|
8
|
+
* with `${env:VAR}` expansion applied at load time.
|
|
9
|
+
*/
|
|
10
|
+
import type { McpServerSpec } from './config.js';
|
|
11
|
+
import { type McpClientLike, type McpToolDef, type McpCallResult, type McpResource, type McpPromptDef } from './client.js';
|
|
12
|
+
/** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
|
|
13
|
+
export declare function parseSseBody(text: string): unknown[];
|
|
14
|
+
export declare class RemoteMcpClient implements McpClientLike {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
private readonly spec;
|
|
17
|
+
private nextId;
|
|
18
|
+
private readonly headers;
|
|
19
|
+
private readonly timeoutMs;
|
|
20
|
+
constructor(name: string, spec: McpServerSpec);
|
|
21
|
+
connect(): Promise<void>;
|
|
22
|
+
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
23
|
+
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
24
|
+
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
25
|
+
promptsList(signal?: AbortSignal): Promise<McpPromptDef[]>;
|
|
26
|
+
promptsGet(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
private request;
|
|
29
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { McpError } from './client.js';
|
|
2
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
/** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
|
|
5
|
+
export function parseSseBody(text) {
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const line of text.split('\n')) {
|
|
8
|
+
const t = line.trim();
|
|
9
|
+
if (!t.startsWith('data:'))
|
|
10
|
+
continue;
|
|
11
|
+
const payload = t.slice(5).trim();
|
|
12
|
+
if (payload === '[DONE]')
|
|
13
|
+
continue;
|
|
14
|
+
try {
|
|
15
|
+
out.push(JSON.parse(payload));
|
|
16
|
+
}
|
|
17
|
+
catch { /* ignore malformed lines */ }
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
export class RemoteMcpClient {
|
|
22
|
+
name;
|
|
23
|
+
spec;
|
|
24
|
+
nextId = 1;
|
|
25
|
+
headers;
|
|
26
|
+
timeoutMs;
|
|
27
|
+
constructor(name, spec) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
this.spec = spec;
|
|
30
|
+
if (!spec.url)
|
|
31
|
+
throw new McpError(`mcp server "${name}" has no url`, 'INVALID_SPEC');
|
|
32
|
+
this.headers = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...(spec.headers ?? {}) };
|
|
33
|
+
this.timeoutMs = spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
34
|
+
}
|
|
35
|
+
async connect() {
|
|
36
|
+
// Handshake doubles as reachability probe; failures surface typed errors.
|
|
37
|
+
await this.request('initialize', {
|
|
38
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
39
|
+
capabilities: {},
|
|
40
|
+
clientInfo: { name: 'klyro', version: 'remote' },
|
|
41
|
+
});
|
|
42
|
+
// Best-effort initialized notification (no id → no response expected).
|
|
43
|
+
try {
|
|
44
|
+
await fetch(this.spec.url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: this.headers,
|
|
47
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
48
|
+
signal: AbortSignal.timeout(5000),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch { /* notification is best-effort */ }
|
|
52
|
+
}
|
|
53
|
+
async listTools(signal) {
|
|
54
|
+
const res = (await this.request('tools/list', {}, this.timeoutMs, signal));
|
|
55
|
+
return Array.isArray(res.tools) ? res.tools : [];
|
|
56
|
+
}
|
|
57
|
+
async callTool(name, args, signal) {
|
|
58
|
+
const res = (await this.request('tools/call', { name, arguments: args ?? {} }, this.timeoutMs, signal));
|
|
59
|
+
const blocks = Array.isArray(res.content) ? res.content : [];
|
|
60
|
+
const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
|
|
61
|
+
return { text, isError: res.isError === true, raw: res };
|
|
62
|
+
}
|
|
63
|
+
async listResources(signal) {
|
|
64
|
+
const res = (await this.request('resources/list', {}, this.timeoutMs, signal));
|
|
65
|
+
return Array.isArray(res.resources) ? res.resources : [];
|
|
66
|
+
}
|
|
67
|
+
async promptsList(signal) {
|
|
68
|
+
try {
|
|
69
|
+
const res = (await this.request('prompts/list', {}, this.timeoutMs, signal));
|
|
70
|
+
return Array.isArray(res.prompts) ? res.prompts : [];
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof McpError && err.code === 'SERVER_ERROR' && err.details?.code === -32601)
|
|
74
|
+
return [];
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async promptsGet(name, args, signal) {
|
|
79
|
+
const res = (await this.request('prompts/get', { name, arguments: args ?? {} }, this.timeoutMs, signal));
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (typeof res.description === 'string' && res.description)
|
|
82
|
+
parts.push(res.description);
|
|
83
|
+
for (const m of Array.isArray(res.messages) ? res.messages : []) {
|
|
84
|
+
const c = m.content;
|
|
85
|
+
if (c && typeof c.text === 'string')
|
|
86
|
+
parts.push(c.text);
|
|
87
|
+
else if (c !== undefined)
|
|
88
|
+
parts.push(JSON.stringify(c));
|
|
89
|
+
}
|
|
90
|
+
return parts.join('\n\n');
|
|
91
|
+
}
|
|
92
|
+
async close() {
|
|
93
|
+
// Stateless HTTP — nothing to tear down.
|
|
94
|
+
}
|
|
95
|
+
async request(method, params, timeoutMs = this.timeoutMs, signal) {
|
|
96
|
+
if (signal?.aborted)
|
|
97
|
+
throw new McpError(`mcp call "${method}" aborted`, 'ABORTED');
|
|
98
|
+
const ctrl = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
100
|
+
const onAbort = () => ctrl.abort();
|
|
101
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
const id = this.nextId++;
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(this.spec.url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: this.headers,
|
|
107
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
108
|
+
signal: ctrl.signal,
|
|
109
|
+
});
|
|
110
|
+
const ctype = res.headers.get('content-type') ?? '';
|
|
111
|
+
const text = await res.text();
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw new McpError(`mcp server "${this.name}" HTTP ${res.status}: ${text.slice(0, 300)}`, 'HTTP_ERROR', { status: res.status });
|
|
114
|
+
let body;
|
|
115
|
+
if (ctype.includes('text/event-stream')) {
|
|
116
|
+
const events = parseSseBody(text);
|
|
117
|
+
const last = [...events].reverse().find((e) => typeof e === 'object' && e !== null && 'result' in e);
|
|
118
|
+
const errs = [...events].reverse().find((e) => typeof e === 'object' && e !== null && 'error' in e);
|
|
119
|
+
if (!last && errs?.error)
|
|
120
|
+
throw new McpError(`mcp server "${this.name}" error: ${errs.error.message ?? 'unknown'}`, 'SERVER_ERROR', errs.error);
|
|
121
|
+
if (!last)
|
|
122
|
+
throw new McpError(`mcp server "${this.name}" empty SSE stream`, 'PROTOCOL_ERROR');
|
|
123
|
+
body = last.result;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
body = JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new McpError(`mcp server "${this.name}" returned non-JSON`, 'PROTOCOL_ERROR');
|
|
131
|
+
}
|
|
132
|
+
const env = body;
|
|
133
|
+
if (env && typeof env === 'object' && 'error' in env && env.error) {
|
|
134
|
+
throw new McpError(`mcp server "${this.name}" error: ${env.error.message ?? 'unknown'}`, 'SERVER_ERROR', env.error);
|
|
135
|
+
}
|
|
136
|
+
body = env && typeof env === 'object' && 'result' in env ? env.result : body;
|
|
137
|
+
}
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err instanceof McpError)
|
|
142
|
+
throw err;
|
|
143
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
144
|
+
throw new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT');
|
|
145
|
+
}
|
|
146
|
+
throw new McpError(`mcp call "${method}" failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR');
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
signal?.removeEventListener('abort', onAbort);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -11,7 +11,17 @@ export type ApprovalChoice =
|
|
|
11
11
|
/** Yes, and auto-allow this pattern for the rest of the session. */
|
|
12
12
|
| 'always'
|
|
13
13
|
/** Yes, session-allow AND persist the pattern to settings (survives restarts). */
|
|
14
|
-
| 'always-persist'
|
|
14
|
+
| 'always-persist'
|
|
15
|
+
/**
|
|
16
|
+
* Yes, but with edited input. Produced by the TUI modal (`e`): the
|
|
17
|
+
* runtime re-validates + re-evaluates policy on `editedInput` (bounded
|
|
18
|
+
* re-prompt loop) instead of running the original call. The stdin prompt
|
|
19
|
+
* has no editor — `e` there stays `deny`.
|
|
20
|
+
*/
|
|
21
|
+
| {
|
|
22
|
+
kind: 'edit';
|
|
23
|
+
editedInput: Record<string, unknown>;
|
|
24
|
+
};
|
|
15
25
|
export interface ApprovalRequest {
|
|
16
26
|
toolName: string;
|
|
17
27
|
reason: string;
|
|
@@ -37,6 +47,10 @@ export declare function sanitizeForPrompt(s: string): string;
|
|
|
37
47
|
* so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
|
|
38
48
|
* persist to settings. Case MUST be checked before lowercasing — the old TUI
|
|
39
49
|
* code lowercased first, so `A` silently became session-only `always`.
|
|
50
|
+
*
|
|
51
|
+
* NOTE: `e` maps to `deny` here, but the TUI modal intercepts `e` BEFORE
|
|
52
|
+
* this mapper to open inline edit mode (edit-and-retry). Only callers
|
|
53
|
+
* without an editor (stdin prompt) should rely on the `e`→`deny` mapping.
|
|
40
54
|
*/
|
|
41
55
|
export declare function approvalChoiceForKey(inputStr: string): ApprovalChoice | 'expand' | 'explain' | null;
|
|
42
56
|
/** Default prompt backed by readline on stdin/stdout. */
|
package/dist/policy/approval.js
CHANGED
|
@@ -30,6 +30,10 @@ export function sanitizeForPrompt(s) {
|
|
|
30
30
|
* so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
|
|
31
31
|
* persist to settings. Case MUST be checked before lowercasing — the old TUI
|
|
32
32
|
* code lowercased first, so `A` silently became session-only `always`.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: `e` maps to `deny` here, but the TUI modal intercepts `e` BEFORE
|
|
35
|
+
* this mapper to open inline edit mode (edit-and-retry). Only callers
|
|
36
|
+
* without an editor (stdin prompt) should rely on the `e`→`deny` mapping.
|
|
33
37
|
*/
|
|
34
38
|
export function approvalChoiceForKey(inputStr) {
|
|
35
39
|
if (inputStr === 'A')
|
|
@@ -101,6 +105,10 @@ export class PatternApprovalCache {
|
|
|
101
105
|
if (key && this.session.has(key))
|
|
102
106
|
return 'allow';
|
|
103
107
|
const choice = await this.inner.ask(req);
|
|
108
|
+
// Edited input is a new call, not an approval of the pattern — pass
|
|
109
|
+
// through without caching so the re-evaluated call prompts on its own.
|
|
110
|
+
if (typeof choice === 'object')
|
|
111
|
+
return choice;
|
|
104
112
|
if ((choice === 'always' || choice === 'always-persist') && key) {
|
|
105
113
|
this.session.add(key);
|
|
106
114
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single provider registration point.
|
|
3
|
+
*
|
|
4
|
+
* Everything a new provider needs lives in ONE entry here:
|
|
5
|
+
* - default base URL + model (hosted) or probe URL (local)
|
|
6
|
+
* - wire protocol kind (OpenAI-compatible vs Anthropic)
|
|
7
|
+
* - key env vars for resolution
|
|
8
|
+
*
|
|
9
|
+
* Consumers:
|
|
10
|
+
* - `src/providers.ts` local-probe list + hosted defaults derive from this
|
|
11
|
+
* - `src/providers/model-info.ts` pricing stays model-keyed (unchanged)
|
|
12
|
+
* - `registerEndpoint()` allows programmatic/enterprise providers
|
|
13
|
+
*/
|
|
14
|
+
export type ProviderKind = 'openai-compatible' | 'anthropic';
|
|
15
|
+
export interface ProviderEndpoint {
|
|
16
|
+
/** Stable id, e.g. 'openai', 'ollama'. Lowercase + dashes. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Human label for doctor/help output. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Wire protocol for adapter selection. */
|
|
21
|
+
kind: ProviderKind;
|
|
22
|
+
/** Default base URL (hosted) or probe URL (local). */
|
|
23
|
+
baseURL: string;
|
|
24
|
+
/** Model used when nothing else specifies one. */
|
|
25
|
+
defaultModel: string;
|
|
26
|
+
/** Local servers only: probed in order with `GET <base><probePath>`. */
|
|
27
|
+
probePath?: string;
|
|
28
|
+
/** Env vars tried (in order) for this provider's key. */
|
|
29
|
+
apiKeyEnv?: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Register (or override) a provider endpoint programmatically. */
|
|
32
|
+
export declare function registerEndpoint(def: ProviderEndpoint): void;
|
|
33
|
+
/** All endpoints: builtins with custom overrides applied. */
|
|
34
|
+
export declare function listEndpoints(): ProviderEndpoint[];
|
|
35
|
+
/** Find one endpoint by id (custom wins). */
|
|
36
|
+
export declare function getEndpoint(id: string): ProviderEndpoint | undefined;
|
|
37
|
+
/** Local-probe candidates in order (entries with probePath). */
|
|
38
|
+
export declare function localProbeEndpoints(): ProviderEndpoint[];
|
|
39
|
+
/**
|
|
40
|
+
* Guess the provider id from a base URL (host matching). Returns undefined
|
|
41
|
+
* for unknown hosts — callers treat that as generic OpenAI-compatible.
|
|
42
|
+
*/
|
|
43
|
+
export declare function inferProviderId(baseURL: string): string | undefined;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const BUILTINS = [
|
|
2
|
+
{
|
|
3
|
+
id: 'openai',
|
|
4
|
+
label: 'OpenAI',
|
|
5
|
+
kind: 'openai-compatible',
|
|
6
|
+
baseURL: 'https://api.openai.com/v1',
|
|
7
|
+
defaultModel: 'gpt-4o-mini',
|
|
8
|
+
apiKeyEnv: ['KLYRO_API_KEY', 'OPENAI_API_KEY'],
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: 'anthropic',
|
|
12
|
+
label: 'Anthropic',
|
|
13
|
+
kind: 'anthropic',
|
|
14
|
+
baseURL: 'https://api.anthropic.com/v1',
|
|
15
|
+
defaultModel: 'claude-3-5-sonnet-20240620',
|
|
16
|
+
apiKeyEnv: ['KLYRO_API_KEY', 'ANTHROPIC_API_KEY'],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'ollama',
|
|
20
|
+
label: 'Ollama',
|
|
21
|
+
kind: 'openai-compatible',
|
|
22
|
+
baseURL: 'http://localhost:11434/v1',
|
|
23
|
+
defaultModel: 'llama3.2',
|
|
24
|
+
probePath: '/models',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'lmstudio',
|
|
28
|
+
label: 'LM Studio',
|
|
29
|
+
kind: 'openai-compatible',
|
|
30
|
+
baseURL: 'http://localhost:1234/v1',
|
|
31
|
+
defaultModel: 'local-model',
|
|
32
|
+
probePath: '/models',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'vllm',
|
|
36
|
+
label: 'vLLM',
|
|
37
|
+
kind: 'openai-compatible',
|
|
38
|
+
baseURL: 'http://localhost:8000/v1',
|
|
39
|
+
defaultModel: 'meta-llama/Llama-3-8B-Instruct',
|
|
40
|
+
probePath: '/models',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'llamacpp',
|
|
44
|
+
label: 'llama.cpp',
|
|
45
|
+
kind: 'openai-compatible',
|
|
46
|
+
baseURL: 'http://localhost:8080/v1',
|
|
47
|
+
defaultModel: 'local-model',
|
|
48
|
+
probePath: '/models',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
const custom = new Map();
|
|
52
|
+
function validateEndpoint(def) {
|
|
53
|
+
if (!/^[a-z0-9-]{1,32}$/.test(def.id))
|
|
54
|
+
throw new Error(`invalid provider id "${def.id}"`);
|
|
55
|
+
if (!def.baseURL || !def.defaultModel)
|
|
56
|
+
throw new Error(`provider "${def.id}" needs baseURL + defaultModel`);
|
|
57
|
+
}
|
|
58
|
+
/** Register (or override) a provider endpoint programmatically. */
|
|
59
|
+
export function registerEndpoint(def) {
|
|
60
|
+
validateEndpoint(def);
|
|
61
|
+
custom.set(def.id, def);
|
|
62
|
+
}
|
|
63
|
+
/** All endpoints: builtins with custom overrides applied. */
|
|
64
|
+
export function listEndpoints() {
|
|
65
|
+
const byId = new Map();
|
|
66
|
+
for (const d of BUILTINS)
|
|
67
|
+
byId.set(d.id, d);
|
|
68
|
+
for (const [id, d] of custom)
|
|
69
|
+
byId.set(id, d);
|
|
70
|
+
return [...byId.values()];
|
|
71
|
+
}
|
|
72
|
+
/** Find one endpoint by id (custom wins). */
|
|
73
|
+
export function getEndpoint(id) {
|
|
74
|
+
return custom.get(id) ?? BUILTINS.find((d) => d.id === id);
|
|
75
|
+
}
|
|
76
|
+
/** Local-probe candidates in order (entries with probePath). */
|
|
77
|
+
export function localProbeEndpoints() {
|
|
78
|
+
return listEndpoints().filter((d) => d.probePath !== undefined);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Guess the provider id from a base URL (host matching). Returns undefined
|
|
82
|
+
* for unknown hosts — callers treat that as generic OpenAI-compatible.
|
|
83
|
+
*/
|
|
84
|
+
export function inferProviderId(baseURL) {
|
|
85
|
+
let host = '';
|
|
86
|
+
try {
|
|
87
|
+
host = new URL(baseURL).hostname.toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (host === 'api.openai.com')
|
|
93
|
+
return 'openai';
|
|
94
|
+
if (host === 'api.anthropic.com')
|
|
95
|
+
return 'anthropic';
|
|
96
|
+
for (const d of listEndpoints()) {
|
|
97
|
+
try {
|
|
98
|
+
if (new URL(d.baseURL).hostname.toLowerCase() === host && d.id !== 'openai' && d.id !== 'anthropic')
|
|
99
|
+
return d.id;
|
|
100
|
+
}
|
|
101
|
+
catch { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
package/dist/providers.js
CHANGED
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
* 4. Fall back to api.openai.com with a clear error if no key
|
|
13
13
|
*/
|
|
14
14
|
import { assertSafeBaseURL, normalizeBaseURL } from './chat.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
import { localProbeEndpoints } from './providers/endpoints.js';
|
|
16
|
+
/**
|
|
17
|
+
* Local probe candidates, derived from the single provider registry
|
|
18
|
+
* (`providers/endpoints.ts`) — adding a local server there automatically
|
|
19
|
+
* extends probing here.
|
|
20
|
+
*/
|
|
21
|
+
function localProbeList() {
|
|
22
|
+
return localProbeEndpoints().map((d) => ({ name: d.label, baseURL: d.baseURL, defaultModel: d.defaultModel, probePath: d.probePath ?? '/models' }));
|
|
23
|
+
}
|
|
21
24
|
/**
|
|
22
25
|
* Resolve which provider to use. Does not throw; returns `null` if nothing
|
|
23
26
|
* is reachable.
|
|
@@ -147,8 +150,8 @@ export async function resolveProvider() {
|
|
|
147
150
|
};
|
|
148
151
|
}
|
|
149
152
|
// Probe local endpoints
|
|
150
|
-
for (const ep of
|
|
151
|
-
if (await probeLocal(ep.baseURL)) {
|
|
153
|
+
for (const ep of localProbeList()) {
|
|
154
|
+
if (await probeLocal(ep.baseURL, ep.probePath)) {
|
|
152
155
|
return {
|
|
153
156
|
baseURL: ep.baseURL,
|
|
154
157
|
apiKey: '',
|
|
@@ -160,13 +163,13 @@ export async function resolveProvider() {
|
|
|
160
163
|
return null;
|
|
161
164
|
}
|
|
162
165
|
/** Hit a cheap endpoint to check if a local server is up. */
|
|
163
|
-
async function probeLocal(baseURL) {
|
|
166
|
+
async function probeLocal(baseURL, probePath = '/models') {
|
|
164
167
|
const ctrl = new AbortController();
|
|
165
168
|
const t = setTimeout(() => ctrl.abort(), 600);
|
|
166
169
|
try {
|
|
167
170
|
// Try the OpenAI-style /models endpoint; some servers (Ollama before load)
|
|
168
171
|
// answer /, others only /v1/models.
|
|
169
|
-
const res = await fetch(`${baseURL.replace(/\/+$/, '')}
|
|
172
|
+
const res = await fetch(`${baseURL.replace(/\/+$/, '')}${probePath}`, {
|
|
170
173
|
method: 'GET',
|
|
171
174
|
signal: ctrl.signal,
|
|
172
175
|
});
|