@moxxy/plugin-provider-local 0.21.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,46 @@
1
+ import { type ModelDescriptor } from '@moxxy/sdk';
2
+ /**
3
+ * Default endpoint: Ollama's OpenAI-compatible server. Override with the
4
+ * `LOCAL_MODEL_BASE_URL` env var (or `provider.config.baseURL`) to point at LM
5
+ * Studio (`http://localhost:1234/v1`), llama.cpp, vLLM, or a remote box.
6
+ */
7
+ export declare const DEFAULT_LOCAL_BASE_URL = "http://localhost:11434/v1";
8
+ /**
9
+ * A small catalog of popular local models as sensible defaults. Local model ids
10
+ * vary by whatever the user has pulled, so this is intentionally short — an
11
+ * unlisted id (e.g. `mistral-small`, `phi4`, a custom tag) still works, since
12
+ * the id is passed straight through to the local server. The catalog only seeds
13
+ * the `/model` picker and gives context-window budgets a starting point.
14
+ *
15
+ * `supportsTools` is optimistic only for ids that reliably do OpenAI
16
+ * tool-calling (llama3.3 / qwen). Reasoning/experimental builds (deepseek-r1,
17
+ * gpt-oss) and many quantized pulls don't, so they default conservative rather
18
+ * than advertising a capability the backend ignores or 400s on. Confirm per
19
+ * pulled model.
20
+ */
21
+ export declare const localModels: ReadonlyArray<ModelDescriptor>;
22
+ /**
23
+ * Test-only: clear the once-per-host warning memo so a suite can assert
24
+ * deterministic warn counts independent of which other tests ran first. Not part
25
+ * of the runtime API — the memo is process-lifetime and never reset in prod.
26
+ * @internal
27
+ */
28
+ export declare function __resetRemoteWarningsForTests(): void;
29
+ /**
30
+ * Local models via any OpenAI-compatible server (Ollama, LM Studio, llama.cpp,
31
+ * vLLM). Reuses the shared {@link defineOpenAICompatProvider} pointed at a
32
+ * localhost base URL, with the `local` slug forced on. `validate: false` and a
33
+ * placeholder API key: local servers don't authenticate, so the credential path
34
+ * supplies a placeholder (see `resolveProviderCredentials` in the CLI),
35
+ * activation never prompts, and we never probe a possibly-offline box.
36
+ *
37
+ * Env-var resolution is duplicated by design: in the CLI flow
38
+ * `resolveProviderCredentials` pre-resolves `apiKey`/`baseURL` into the config
39
+ * (so the env reads below are unreachable there), but non-CLI callers
40
+ * (desktop / provider-admin direct `createClient`) don't — these `resolve*`
41
+ * functions are the authoritative readers for them, with identical precedence.
42
+ */
43
+ export declare const localProviderDef: import("@moxxy/sdk").ProviderDef;
44
+ export declare const localPlugin: import("@moxxy/sdk").Plugin;
45
+ export default localPlugin;
46
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAM5E;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,8BAA8B,CAAC;AAkBlE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,EAAE,aAAa,CAAC,eAAe,CAMtD,CAAC;AAmBF;;;;;GAKG;AACH,wBAAgB,6BAA6B,IAAI,IAAI,CAEpD;AAoDD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,kCAa3B,CAAC;AAEH,eAAO,MAAM,WAAW,6BAItB,CAAC;AAEH,eAAe,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,150 @@
1
+ import { definePlugin, MoxxyError } from '@moxxy/sdk';
2
+ import { defineOpenAICompatProvider, } from '@moxxy/plugin-provider-openai';
3
+ /**
4
+ * Default endpoint: Ollama's OpenAI-compatible server. Override with the
5
+ * `LOCAL_MODEL_BASE_URL` env var (or `provider.config.baseURL`) to point at LM
6
+ * Studio (`http://localhost:1234/v1`), llama.cpp, vLLM, or a remote box.
7
+ */
8
+ export const DEFAULT_LOCAL_BASE_URL = 'http://localhost:11434/v1';
9
+ const LOCAL_DEFAULT_MODEL = 'llama3.3';
10
+ /**
11
+ * Local servers don't authenticate, but the OpenAI SDK requires a non-empty
12
+ * key. Use a harmless placeholder unless one is explicitly provided.
13
+ */
14
+ const LOCAL_PLACEHOLDER_KEY = 'local';
15
+ /**
16
+ * Conservative context-window floor for the seed catalog. Stock Ollama defaults
17
+ * to `num_ctx` 2k–4k and other servers vary widely; the value here only seeds
18
+ * the compaction/elision budget. Over-claiming (e.g. 131k) means the budget
19
+ * guard never fires and the backend silently truncates the prompt once the real
20
+ * window is exceeded — under-claiming degrades gracefully, so floor it. Raise it
21
+ * via `provider.config.defaultModel`/a pinned descriptor when `num_ctx` is known.
22
+ */
23
+ const LOCAL_CONTEXT_FLOOR = 8_192;
24
+ /**
25
+ * A small catalog of popular local models as sensible defaults. Local model ids
26
+ * vary by whatever the user has pulled, so this is intentionally short — an
27
+ * unlisted id (e.g. `mistral-small`, `phi4`, a custom tag) still works, since
28
+ * the id is passed straight through to the local server. The catalog only seeds
29
+ * the `/model` picker and gives context-window budgets a starting point.
30
+ *
31
+ * `supportsTools` is optimistic only for ids that reliably do OpenAI
32
+ * tool-calling (llama3.3 / qwen). Reasoning/experimental builds (deepseek-r1,
33
+ * gpt-oss) and many quantized pulls don't, so they default conservative rather
34
+ * than advertising a capability the backend ignores or 400s on. Confirm per
35
+ * pulled model.
36
+ */
37
+ export const localModels = [
38
+ { id: 'llama3.3', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: true, supportsStreaming: true },
39
+ { id: 'qwen3', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: true, supportsStreaming: true },
40
+ { id: 'qwen2.5-coder', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: true, supportsStreaming: true },
41
+ { id: 'deepseek-r1', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: false, supportsStreaming: true },
42
+ { id: 'gpt-oss', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: false, supportsStreaming: true },
43
+ ];
44
+ /** Hosts that keep traffic on the local machine — no data egress. */
45
+ function isLoopbackHost(hostname) {
46
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
47
+ if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '::')
48
+ return true;
49
+ if (h.endsWith('.localhost'))
50
+ return true;
51
+ // IPv4 loopback block 127.0.0.0/8.
52
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
53
+ }
54
+ // Warn at most once per distinct non-loopback host. Bounded so a pathological
55
+ // caller that cycles base URLs can't grow this without limit; once the bound is
56
+ // reached, further never-before-seen hosts are silently suppressed (rather than
57
+ // re-warning on every call) — 64 distinct remote endpoints is already
58
+ // pathological, and unbounded log spam is its own failure mode.
59
+ const warnedRemoteHosts = new Set();
60
+ const MAX_WARNED_HOSTS = 64;
61
+ /**
62
+ * Test-only: clear the once-per-host warning memo so a suite can assert
63
+ * deterministic warn counts independent of which other tests ran first. Not part
64
+ * of the runtime API — the memo is process-lifetime and never reset in prod.
65
+ * @internal
66
+ */
67
+ export function __resetRemoteWarningsForTests() {
68
+ warnedRemoteHosts.clear();
69
+ }
70
+ /**
71
+ * Resolve and validate the base URL the prompt (which can carry session context,
72
+ * file contents, and shown secrets) will be POSTed to. Precedence matches the
73
+ * prior behaviour: `provider.config.baseURL` → `LOCAL_MODEL_BASE_URL` env →
74
+ * Ollama default. Because this provider is branded `local` and runs with
75
+ * `validate: false` (no setup probe), a mistaken/poisoned URL would otherwise
76
+ * silently redirect ALL traffic (and the placeholder credential) to an
77
+ * arbitrary endpoint over an arbitrary scheme. So: reject anything that isn't
78
+ * parseable http/https, and surface a one-time warning when the resolved host
79
+ * leaves the local machine so egress is visible (remote boxes are explicitly
80
+ * supported, hence a warning rather than a hard block).
81
+ */
82
+ function resolveLocalBaseURL(cfg) {
83
+ const raw = cfg.baseURL ?? process.env.LOCAL_MODEL_BASE_URL ?? DEFAULT_LOCAL_BASE_URL;
84
+ let url;
85
+ try {
86
+ url = new URL(raw);
87
+ }
88
+ catch (cause) {
89
+ throw new MoxxyError({
90
+ code: 'CONFIG_INVALID',
91
+ message: `local provider baseURL is not a valid URL: ${raw}`,
92
+ hint: 'Set provider.config.baseURL or LOCAL_MODEL_BASE_URL to e.g. http://localhost:11434/v1',
93
+ context: { provider: 'local' },
94
+ cause,
95
+ });
96
+ }
97
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
98
+ throw new MoxxyError({
99
+ code: 'CONFIG_INVALID',
100
+ message: `local provider baseURL must use http or https, got ${url.protocol}//`,
101
+ hint: 'Set provider.config.baseURL or LOCAL_MODEL_BASE_URL to an http(s) endpoint.',
102
+ context: { provider: 'local', url: raw },
103
+ });
104
+ }
105
+ if (!isLoopbackHost(url.hostname) &&
106
+ !warnedRemoteHosts.has(url.hostname) &&
107
+ warnedRemoteHosts.size < MAX_WARNED_HOSTS) {
108
+ // Only warn for hosts we can record, so the once-per-host guarantee holds
109
+ // even past the cap (an unrecorded host would otherwise re-warn every call).
110
+ warnedRemoteHosts.add(url.hostname);
111
+ console.warn(`[local] sending prompts to a non-local endpoint (${url.host}). ` +
112
+ 'Conversation context, file contents and shown secrets are POSTed there.');
113
+ }
114
+ return raw;
115
+ }
116
+ /**
117
+ * Local models via any OpenAI-compatible server (Ollama, LM Studio, llama.cpp,
118
+ * vLLM). Reuses the shared {@link defineOpenAICompatProvider} pointed at a
119
+ * localhost base URL, with the `local` slug forced on. `validate: false` and a
120
+ * placeholder API key: local servers don't authenticate, so the credential path
121
+ * supplies a placeholder (see `resolveProviderCredentials` in the CLI),
122
+ * activation never prompts, and we never probe a possibly-offline box.
123
+ *
124
+ * Env-var resolution is duplicated by design: in the CLI flow
125
+ * `resolveProviderCredentials` pre-resolves `apiKey`/`baseURL` into the config
126
+ * (so the env reads below are unreachable there), but non-CLI callers
127
+ * (desktop / provider-admin direct `createClient`) don't — these `resolve*`
128
+ * functions are the authoritative readers for them, with identical precedence.
129
+ */
130
+ export const localProviderDef = defineOpenAICompatProvider({
131
+ name: 'local',
132
+ baseURL: DEFAULT_LOCAL_BASE_URL,
133
+ defaultModel: LOCAL_DEFAULT_MODEL,
134
+ models: localModels,
135
+ validate: false,
136
+ resolveApiKey: (cfg) => cfg.apiKey ?? process.env.LOCAL_API_KEY ?? LOCAL_PLACEHOLDER_KEY,
137
+ resolveBaseURL: resolveLocalBaseURL,
138
+ auth: {
139
+ kind: 'apiKey',
140
+ envVar: 'LOCAL_API_KEY',
141
+ hint: 'optional — local servers need no key; set LOCAL_MODEL_BASE_URL for a non-Ollama endpoint',
142
+ },
143
+ });
144
+ export const localPlugin = definePlugin({
145
+ name: '@moxxy/plugin-provider-local',
146
+ version: '0.0.0',
147
+ providers: [localProviderDef],
148
+ });
149
+ export default localPlugin;
150
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@moxxy/plugin-provider-local",
3
+ "version": "0.21.1",
4
+ "description": "Local-model LLMProvider plugin for moxxy. Talks to any OpenAI-compatible local server (Ollama, LM Studio, llama.cpp, vLLM) — no API key required.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "provider",
9
+ "ollama",
10
+ "local",
11
+ "llm"
12
+ ],
13
+ "homepage": "https://moxxy.ai",
14
+ "bugs": {
15
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
20
+ "directory": "packages/plugin-provider-local"
21
+ },
22
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "moxxy": {
41
+ "plugin": {
42
+ "entry": "./dist/index.js",
43
+ "kind": "provider"
44
+ }
45
+ },
46
+ "dependencies": {
47
+ "@moxxy/plugin-provider-openai": "0.21.1",
48
+ "@moxxy/sdk": "0.21.1"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22.10.0",
52
+ "typescript": "^5.7.3",
53
+ "vitest": "^2.1.8",
54
+ "zod": "^3.24.0",
55
+ "@moxxy/tsconfig": "0.0.0",
56
+ "@moxxy/vitest-preset": "0.0.0"
57
+ },
58
+ "scripts": {
59
+ "build": "tsc -p tsconfig.json",
60
+ "typecheck": "tsc -p tsconfig.json --noEmit",
61
+ "test": "vitest run",
62
+ "clean": "rm -rf dist .turbo"
63
+ }
64
+ }
@@ -0,0 +1,126 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { MoxxyError } from '@moxxy/sdk';
3
+ import {
4
+ localPlugin,
5
+ localProviderDef,
6
+ localModels,
7
+ DEFAULT_LOCAL_BASE_URL,
8
+ __resetRemoteWarningsForTests,
9
+ } from './index.js';
10
+
11
+ describe('@moxxy/plugin-provider-local', () => {
12
+ it('registers the local provider', () => {
13
+ expect(localPlugin.providers?.map((p) => p.name)).toEqual(['local']);
14
+ });
15
+
16
+ it('has no validateKey (local servers do not authenticate)', () => {
17
+ expect(localProviderDef.validateKey).toBeUndefined();
18
+ });
19
+
20
+ it('activates with no key — createClient supplies a placeholder and the Ollama base URL', () => {
21
+ const client = localProviderDef.createClient({});
22
+ expect(client.name).toBe('local');
23
+ expect(client.models).toEqual(localModels);
24
+ expect(DEFAULT_LOCAL_BASE_URL).toBe('http://localhost:11434/v1');
25
+ });
26
+
27
+ it('passes through unlisted local model ids (catalog is only a default set)', () => {
28
+ // The catalog is short on purpose; the provider streams any model id the
29
+ // local server knows. Sanity-check the seed catalog is non-empty.
30
+ expect(localModels.length).toBeGreaterThan(0);
31
+ expect(localModels.every((m) => m.supportsStreaming)).toBe(true);
32
+ });
33
+
34
+ it('seeds a conservative context window (never over-claims the server window)', () => {
35
+ // Over-claiming defeats the compaction/elision budget; under-claiming is the
36
+ // safe direction. Stock Ollama num_ctx is 2k–4k, so the seed must stay small.
37
+ expect(localModels.every((m) => m.contextWindow <= 8_192)).toBe(true);
38
+ expect(localModels.every((m) => m.contextWindow > 0)).toBe(true);
39
+ });
40
+
41
+ describe('baseURL validation (SSRF / data-egress guard)', () => {
42
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
43
+
44
+ beforeEach(() => {
45
+ warn.mockClear();
46
+ __resetRemoteWarningsForTests();
47
+ delete process.env.LOCAL_MODEL_BASE_URL;
48
+ });
49
+ afterEach(() => {
50
+ delete process.env.LOCAL_MODEL_BASE_URL;
51
+ });
52
+
53
+ it('rejects an unparseable baseURL with a structured MoxxyError', () => {
54
+ expect(() => localProviderDef.createClient({ baseURL: 'not a url' })).toThrow(MoxxyError);
55
+ try {
56
+ localProviderDef.createClient({ baseURL: '::::' });
57
+ } catch (err) {
58
+ expect(MoxxyError.isMoxxyError(err)).toBe(true);
59
+ expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
60
+ }
61
+ });
62
+
63
+ it.each(['file:///etc/passwd', 'gopher://evil/', 'ftp://host/x', 'data:text/plain,x'])(
64
+ 'rejects a non-http(s) scheme (%s) instead of handing it to the SDK',
65
+ (bad) => {
66
+ let thrown: unknown;
67
+ try {
68
+ localProviderDef.createClient({ baseURL: bad });
69
+ } catch (err) {
70
+ thrown = err;
71
+ }
72
+ expect(MoxxyError.isMoxxyError(thrown)).toBe(true);
73
+ expect((thrown as MoxxyError).code).toBe('CONFIG_INVALID');
74
+ },
75
+ );
76
+
77
+ it('does not warn for a loopback endpoint (the local happy path)', () => {
78
+ localProviderDef.createClient({ baseURL: 'http://127.0.0.1:11434/v1' });
79
+ localProviderDef.createClient({ baseURL: 'http://localhost:1234/v1' });
80
+ localProviderDef.createClient({ baseURL: 'http://[::1]:11434/v1' });
81
+ localProviderDef.createClient({ baseURL: 'http://dev.localhost/v1' });
82
+ localProviderDef.createClient({ baseURL: 'http://127.5.5.5/v1' });
83
+ expect(warn).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it.each([
87
+ 'http://localhost.evil.com/v1',
88
+ 'http://127.0.0.1.evil.com/v1',
89
+ 'http://[fe80::1]/v1',
90
+ 'http://192.168.1.50:11434/v1',
91
+ ])('warns for a remote host that merely looks loopback-ish (%s)', (baseURL) => {
92
+ // The dangerous direction is mis-classifying a REMOTE host as loopback and
93
+ // silently suppressing the egress warning. These must all warn.
94
+ localProviderDef.createClient({ baseURL });
95
+ expect(warn).toHaveBeenCalled();
96
+ });
97
+
98
+ it('warns once per distinct non-loopback host so data egress is visible', () => {
99
+ localProviderDef.createClient({ baseURL: 'https://remote.example.com/v1' });
100
+ localProviderDef.createClient({ baseURL: 'https://remote.example.com/v2' });
101
+ expect(warn).toHaveBeenCalledTimes(1);
102
+ localProviderDef.createClient({ baseURL: 'http://other.example.org:8080/v1' });
103
+ expect(warn).toHaveBeenCalledTimes(2);
104
+ });
105
+
106
+ it('never re-warns for an already-seen host, even past the bound (no unbounded log spam)', () => {
107
+ // Cycle many distinct remote hosts so the warn cap is reached, then replay
108
+ // the exact same hosts. The worst case the bound protects against is a
109
+ // caller cycling base URLs: hosts beyond the cap must NOT re-warn on every
110
+ // call. So the second pass over the identical hosts must emit zero warns.
111
+ const hosts = Array.from({ length: 80 }, (_, i) => `https://spam-${i}.example.net/v1`);
112
+ for (const baseURL of hosts) localProviderDef.createClient({ baseURL });
113
+ const afterFirstPass = warn.mock.calls.length;
114
+ warn.mockClear();
115
+ for (const baseURL of hosts) localProviderDef.createClient({ baseURL });
116
+ expect(warn).not.toHaveBeenCalled();
117
+ // First pass is itself bounded: at most one warn per distinct host.
118
+ expect(afterFirstPass).toBeLessThanOrEqual(hosts.length);
119
+ });
120
+
121
+ it('honours the LOCAL_MODEL_BASE_URL env fallback for non-CLI callers', () => {
122
+ process.env.LOCAL_MODEL_BASE_URL = 'http://localhost:11434/v1';
123
+ expect(() => localProviderDef.createClient({})).not.toThrow();
124
+ });
125
+ });
126
+ });
package/src/index.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { definePlugin, MoxxyError, type ModelDescriptor } from '@moxxy/sdk';
2
+ import {
3
+ defineOpenAICompatProvider,
4
+ type OpenAICompatConfig,
5
+ } from '@moxxy/plugin-provider-openai';
6
+
7
+ /**
8
+ * Default endpoint: Ollama's OpenAI-compatible server. Override with the
9
+ * `LOCAL_MODEL_BASE_URL` env var (or `provider.config.baseURL`) to point at LM
10
+ * Studio (`http://localhost:1234/v1`), llama.cpp, vLLM, or a remote box.
11
+ */
12
+ export const DEFAULT_LOCAL_BASE_URL = 'http://localhost:11434/v1';
13
+ const LOCAL_DEFAULT_MODEL = 'llama3.3';
14
+ /**
15
+ * Local servers don't authenticate, but the OpenAI SDK requires a non-empty
16
+ * key. Use a harmless placeholder unless one is explicitly provided.
17
+ */
18
+ const LOCAL_PLACEHOLDER_KEY = 'local';
19
+
20
+ /**
21
+ * Conservative context-window floor for the seed catalog. Stock Ollama defaults
22
+ * to `num_ctx` 2k–4k and other servers vary widely; the value here only seeds
23
+ * the compaction/elision budget. Over-claiming (e.g. 131k) means the budget
24
+ * guard never fires and the backend silently truncates the prompt once the real
25
+ * window is exceeded — under-claiming degrades gracefully, so floor it. Raise it
26
+ * via `provider.config.defaultModel`/a pinned descriptor when `num_ctx` is known.
27
+ */
28
+ const LOCAL_CONTEXT_FLOOR = 8_192;
29
+
30
+ /**
31
+ * A small catalog of popular local models as sensible defaults. Local model ids
32
+ * vary by whatever the user has pulled, so this is intentionally short — an
33
+ * unlisted id (e.g. `mistral-small`, `phi4`, a custom tag) still works, since
34
+ * the id is passed straight through to the local server. The catalog only seeds
35
+ * the `/model` picker and gives context-window budgets a starting point.
36
+ *
37
+ * `supportsTools` is optimistic only for ids that reliably do OpenAI
38
+ * tool-calling (llama3.3 / qwen). Reasoning/experimental builds (deepseek-r1,
39
+ * gpt-oss) and many quantized pulls don't, so they default conservative rather
40
+ * than advertising a capability the backend ignores or 400s on. Confirm per
41
+ * pulled model.
42
+ */
43
+ export const localModels: ReadonlyArray<ModelDescriptor> = [
44
+ { id: 'llama3.3', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: true, supportsStreaming: true },
45
+ { id: 'qwen3', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: true, supportsStreaming: true },
46
+ { id: 'qwen2.5-coder', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: true, supportsStreaming: true },
47
+ { id: 'deepseek-r1', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: false, supportsStreaming: true },
48
+ { id: 'gpt-oss', contextWindow: LOCAL_CONTEXT_FLOOR, supportsTools: false, supportsStreaming: true },
49
+ ];
50
+
51
+ /** Hosts that keep traffic on the local machine — no data egress. */
52
+ function isLoopbackHost(hostname: string): boolean {
53
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
54
+ if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '::') return true;
55
+ if (h.endsWith('.localhost')) return true;
56
+ // IPv4 loopback block 127.0.0.0/8.
57
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
58
+ }
59
+
60
+ // Warn at most once per distinct non-loopback host. Bounded so a pathological
61
+ // caller that cycles base URLs can't grow this without limit; once the bound is
62
+ // reached, further never-before-seen hosts are silently suppressed (rather than
63
+ // re-warning on every call) — 64 distinct remote endpoints is already
64
+ // pathological, and unbounded log spam is its own failure mode.
65
+ const warnedRemoteHosts = new Set<string>();
66
+ const MAX_WARNED_HOSTS = 64;
67
+
68
+ /**
69
+ * Test-only: clear the once-per-host warning memo so a suite can assert
70
+ * deterministic warn counts independent of which other tests ran first. Not part
71
+ * of the runtime API — the memo is process-lifetime and never reset in prod.
72
+ * @internal
73
+ */
74
+ export function __resetRemoteWarningsForTests(): void {
75
+ warnedRemoteHosts.clear();
76
+ }
77
+
78
+ /**
79
+ * Resolve and validate the base URL the prompt (which can carry session context,
80
+ * file contents, and shown secrets) will be POSTed to. Precedence matches the
81
+ * prior behaviour: `provider.config.baseURL` → `LOCAL_MODEL_BASE_URL` env →
82
+ * Ollama default. Because this provider is branded `local` and runs with
83
+ * `validate: false` (no setup probe), a mistaken/poisoned URL would otherwise
84
+ * silently redirect ALL traffic (and the placeholder credential) to an
85
+ * arbitrary endpoint over an arbitrary scheme. So: reject anything that isn't
86
+ * parseable http/https, and surface a one-time warning when the resolved host
87
+ * leaves the local machine so egress is visible (remote boxes are explicitly
88
+ * supported, hence a warning rather than a hard block).
89
+ */
90
+ function resolveLocalBaseURL(cfg: OpenAICompatConfig): string {
91
+ const raw = cfg.baseURL ?? process.env.LOCAL_MODEL_BASE_URL ?? DEFAULT_LOCAL_BASE_URL;
92
+ let url: URL;
93
+ try {
94
+ url = new URL(raw);
95
+ } catch (cause) {
96
+ throw new MoxxyError({
97
+ code: 'CONFIG_INVALID',
98
+ message: `local provider baseURL is not a valid URL: ${raw}`,
99
+ hint: 'Set provider.config.baseURL or LOCAL_MODEL_BASE_URL to e.g. http://localhost:11434/v1',
100
+ context: { provider: 'local' },
101
+ cause,
102
+ });
103
+ }
104
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
105
+ throw new MoxxyError({
106
+ code: 'CONFIG_INVALID',
107
+ message: `local provider baseURL must use http or https, got ${url.protocol}//`,
108
+ hint: 'Set provider.config.baseURL or LOCAL_MODEL_BASE_URL to an http(s) endpoint.',
109
+ context: { provider: 'local', url: raw },
110
+ });
111
+ }
112
+ if (
113
+ !isLoopbackHost(url.hostname) &&
114
+ !warnedRemoteHosts.has(url.hostname) &&
115
+ warnedRemoteHosts.size < MAX_WARNED_HOSTS
116
+ ) {
117
+ // Only warn for hosts we can record, so the once-per-host guarantee holds
118
+ // even past the cap (an unrecorded host would otherwise re-warn every call).
119
+ warnedRemoteHosts.add(url.hostname);
120
+ console.warn(
121
+ `[local] sending prompts to a non-local endpoint (${url.host}). ` +
122
+ 'Conversation context, file contents and shown secrets are POSTed there.',
123
+ );
124
+ }
125
+ return raw;
126
+ }
127
+
128
+ /**
129
+ * Local models via any OpenAI-compatible server (Ollama, LM Studio, llama.cpp,
130
+ * vLLM). Reuses the shared {@link defineOpenAICompatProvider} pointed at a
131
+ * localhost base URL, with the `local` slug forced on. `validate: false` and a
132
+ * placeholder API key: local servers don't authenticate, so the credential path
133
+ * supplies a placeholder (see `resolveProviderCredentials` in the CLI),
134
+ * activation never prompts, and we never probe a possibly-offline box.
135
+ *
136
+ * Env-var resolution is duplicated by design: in the CLI flow
137
+ * `resolveProviderCredentials` pre-resolves `apiKey`/`baseURL` into the config
138
+ * (so the env reads below are unreachable there), but non-CLI callers
139
+ * (desktop / provider-admin direct `createClient`) don't — these `resolve*`
140
+ * functions are the authoritative readers for them, with identical precedence.
141
+ */
142
+ export const localProviderDef = defineOpenAICompatProvider({
143
+ name: 'local',
144
+ baseURL: DEFAULT_LOCAL_BASE_URL,
145
+ defaultModel: LOCAL_DEFAULT_MODEL,
146
+ models: localModels,
147
+ validate: false,
148
+ resolveApiKey: (cfg) => cfg.apiKey ?? process.env.LOCAL_API_KEY ?? LOCAL_PLACEHOLDER_KEY,
149
+ resolveBaseURL: resolveLocalBaseURL,
150
+ auth: {
151
+ kind: 'apiKey',
152
+ envVar: 'LOCAL_API_KEY',
153
+ hint: 'optional — local servers need no key; set LOCAL_MODEL_BASE_URL for a non-Ollama endpoint',
154
+ },
155
+ });
156
+
157
+ export const localPlugin = definePlugin({
158
+ name: '@moxxy/plugin-provider-local',
159
+ version: '0.0.0',
160
+ providers: [localProviderDef],
161
+ });
162
+
163
+ export default localPlugin;