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.
Files changed (47) hide show
  1. package/dist/agent/custom-agents.d.ts +3 -0
  2. package/dist/agent/custom-agents.js +96 -0
  3. package/dist/agent/orchestrator.d.ts +22 -2
  4. package/dist/agent/orchestrator.js +30 -4
  5. package/dist/agent/runtime.d.ts +5 -0
  6. package/dist/agent/runtime.js +164 -50
  7. package/dist/checkpoints/store.d.ts +9 -0
  8. package/dist/checkpoints/store.js +20 -0
  9. package/dist/cli/completion.js +2 -2
  10. package/dist/cli/config.d.ts +4 -4
  11. package/dist/cli/eval.d.ts +15 -1
  12. package/dist/cli/eval.js +34 -2
  13. package/dist/cli/hooks.d.ts +54 -5
  14. package/dist/cli/hooks.js +85 -6
  15. package/dist/cli/init.d.ts +6 -0
  16. package/dist/cli/init.js +60 -0
  17. package/dist/cli/repl.js +146 -25
  18. package/dist/cli/run.d.ts +7 -1
  19. package/dist/cli/run.js +92 -50
  20. package/dist/cli/slash/custom.d.ts +25 -0
  21. package/dist/cli/slash/custom.js +166 -0
  22. package/dist/cli/slash/parser.d.ts +9 -1
  23. package/dist/cli/slash/parser.js +31 -9
  24. package/dist/context/memory.js +18 -1
  25. package/dist/eval/harness.d.ts +21 -3
  26. package/dist/eval/harness.js +31 -3
  27. package/dist/eval/judge.d.ts +32 -0
  28. package/dist/eval/judge.js +63 -0
  29. package/dist/eval/tasks.js +134 -0
  30. package/dist/index.js +150 -127
  31. package/dist/mcp/client.d.ts +15 -0
  32. package/dist/mcp/client.js +42 -2
  33. package/dist/mcp/config.d.ts +3 -1
  34. package/dist/mcp/config.js +19 -1
  35. package/dist/mcp/registry.d.ts +13 -0
  36. package/dist/mcp/registry.js +41 -1
  37. package/dist/mcp/remote.d.ts +29 -0
  38. package/dist/mcp/remote.js +153 -0
  39. package/dist/policy/approval.d.ts +15 -1
  40. package/dist/policy/approval.js +8 -0
  41. package/dist/providers/endpoints.d.ts +43 -0
  42. package/dist/providers/endpoints.js +104 -0
  43. package/dist/providers.js +13 -10
  44. package/dist/tui/app.js +140 -13
  45. package/dist/tui/app.test.js +24 -0
  46. package/dist/tui/approval.js +53 -1
  47. package/package.json +1 -1
@@ -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 ?? ((name, spec) => new McpClient(name, spec));
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. */
@@ -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
- const LOCAL_ENDPOINTS = [
16
- { name: 'Ollama', baseURL: 'http://localhost:11434/v1', defaultModel: 'llama3.2' },
17
- { name: 'LM Studio', baseURL: 'http://localhost:1234/v1', defaultModel: 'local-model' },
18
- { name: 'vLLM', baseURL: 'http://localhost:8000/v1', defaultModel: 'meta-llama/Llama-3-8B-Instruct' },
19
- { name: 'llama.cpp', baseURL: 'http://localhost:8080/v1', defaultModel: 'local-model' },
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 LOCAL_ENDPOINTS) {
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(/\/+$/, '')}/models`, {
172
+ const res = await fetch(`${baseURL.replace(/\/+$/, '')}${probePath}`, {
170
173
  method: 'GET',
171
174
  signal: ctrl.signal,
172
175
  });