@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 +21 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +150 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/index.test.ts +126 -0
- package/src/index.ts +163 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|