feat(local-models): self-hosted backbone runs the Arsenal (Ollama + OpenAI-compatible)
This commit is contained in:
1 parent
1e20ce1c03
commit
eac30e4ee3
5 files changed
+136
-35
No files matched your search
@@ -4,7 +4,7 @@
|
||||
* turn 0 and every operator abstains without ever running the Arsenal. These tests pin the fix AND
|
||||
* the parser-hardening from the PR #16 audit (over-match, ReDoS, drift-abstains, string args, Codex coverage).
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Mock the local-agent CLI bridge (LocalAgentAdapter) and the codex spawn/file read (CodexAdapter).
|
||||
@@ -97,3 +97,40 @@ describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the
|
||||
expect(res.toolCalls).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local-model (HTTP) backbone surfaces toolCalls — the keyless-path fix for self-hosted models', () => {
|
||||
const origFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = origFetch; });
|
||||
const mockJson = (body: unknown) => {
|
||||
const spy = vi.fn(async (_url: string, _init: { body: string }) => ({ ok: true, json: async () => body } as unknown as Response));
|
||||
global.fetch = spy as unknown as typeof fetch;
|
||||
return spy;
|
||||
};
|
||||
const localModel = (baseUrl?: string) => new LLMBackbone({ provider: 'local', model: 'llama3', baseUrl } as never);
|
||||
|
||||
it('Ollama wire (/api/chat): parses a tool request out of message.content', async () => {
|
||||
mockJson({ model: 'llama3', message: { role: 'assistant', content: '```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```' } });
|
||||
const res = await localModel('http://localhost:11434/api').chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||
expect(res.toolCalls?.[0]?.name).toBe('nmap_scan');
|
||||
expect(res.finishReason).toBe('tool_calls');
|
||||
});
|
||||
it('OpenAI-compatible wire (/v1): parses out of choices[0].message.content + hits /v1/chat/completions', async () => {
|
||||
const spy = mockJson({ model: 'local', choices: [{ message: { content: '{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"y"}}]}' } }] });
|
||||
const res = await localModel('http://localhost:1234/v1').chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||
expect(res.toolCalls?.[0]?.name).toBe('nmap_scan');
|
||||
expect(spy.mock.calls[0][0] as string).toContain('/v1/chat/completions');
|
||||
});
|
||||
it('prose debrief → final answer, not a forever-abstain', async () => {
|
||||
mockJson({ model: 'llama3', message: { content: 'No exploitable surface found. Final debrief.' } });
|
||||
const res = await localModel().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||
expect(res.toolCalls).toBeUndefined();
|
||||
expect(res.finishReason).toBe('stop');
|
||||
});
|
||||
it('describes the Arsenal to the model (so a local model knows what it can request)', async () => {
|
||||
const spy = mockJson({ message: { content: 'ok' } });
|
||||
await localModel().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
|
||||
const body = JSON.parse((spy.mock.calls[0][1] as { body: string }).body);
|
||||
const sys = body.messages.find((m: { role: string }) => m.role === 'system');
|
||||
expect(sys.content).toContain('nmap_scan');
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -164,7 +164,7 @@ async function interactiveMode(): Promise<void> {
|
||||
showBanner();
|
||||
|
||||
// Check for API keys
|
||||
const providers = getConfiguredProviders().filter(p => p !== 'mock' && p !== 'local');
|
||||
const providers = getConfiguredProviders().filter(p => p !== 'mock');
|
||||
|
||||
if (providers.length === 0) {
|
||||
showBox(
|
||||
@@ -527,7 +527,7 @@ async function openSettings(): Promise<void> {
|
||||
break;
|
||||
|
||||
case 'provider':
|
||||
const providers = getConfiguredProviders().filter(p => p !== 'mock' && p !== 'local');
|
||||
const providers = getConfiguredProviders().filter(p => p !== 'mock');
|
||||
if (providers.length === 0) {
|
||||
showWarning('No API keys configured');
|
||||
break;
|
||||
|
||||
+9
-4
@@ -392,11 +392,11 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
|
||||
local: [
|
||||
{
|
||||
id: 'local-model',
|
||||
name: 'Local Model',
|
||||
name: 'Local model (Ollama / LM Studio / vLLM — set TEMPEST_LOCAL_MODEL)',
|
||||
provider: 'Local',
|
||||
contextWindow: 32000,
|
||||
maxOutput: 4096,
|
||||
capabilities: ['reasoning', 'code'],
|
||||
capabilities: ['reasoning', 'code', 'tools'],
|
||||
},
|
||||
],
|
||||
'local-agent': [
|
||||
@@ -619,8 +619,13 @@ class ConfigManager {
|
||||
actualModel = 'mock-model';
|
||||
break;
|
||||
case 'local':
|
||||
baseUrl = 'http://localhost:11434/api';
|
||||
actualModel = model || 'llama3';
|
||||
// Self-hosted, keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at
|
||||
// any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp)
|
||||
// and TEMPEST_LOCAL_MODEL at the model tag you're serving.
|
||||
baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api';
|
||||
// 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so
|
||||
// TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority.
|
||||
actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${actualProvider}`);
|
||||
|
||||
+72
-26
@@ -705,6 +705,14 @@ class MockAdapter implements LLMProviderAdapter {
|
||||
// LOCAL ADAPTER (Ollama, etc.)
|
||||
// =============================================================================
|
||||
|
||||
// Local model over HTTP — a fully self-hosted, keyless backbone. Talks to Ollama's
|
||||
// native /api/chat by default, or any OpenAI-compatible local server (LM Studio,
|
||||
// vLLM, llama.cpp, Ollama's own /v1) when TEMPEST_LOCAL_BASE_URL is a /v1 endpoint.
|
||||
// Tool-calling is done over TEXT (renderToolContract + parseTextToolCalls, defined
|
||||
// below and shared with the CLI-agent adapters): it works on ANY local model,
|
||||
// whether or not it supports native function-calling — without it a local model
|
||||
// hits the keyless-path abstain bug (no toolCalls → ReAct bails turn 0 → Arsenal
|
||||
// never runs).
|
||||
class LocalAdapter implements LLMProviderAdapter {
|
||||
name = 'local';
|
||||
private config: LLMConfig;
|
||||
@@ -717,28 +725,55 @@ class LocalAdapter implements LLMProviderAdapter {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
|
||||
const baseUrl = this.config.baseUrl || 'http://localhost:11434/api';
|
||||
const url = `${baseUrl}/chat`;
|
||||
// A /v1 base URL means an OpenAI-compatible server (/chat/completions, choices[]).
|
||||
private isOpenAIWire(baseUrl: string): boolean {
|
||||
return /\/v1(\/|$)/.test(baseUrl);
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
model: this.config.model,
|
||||
messages: messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
stream: false,
|
||||
options: {
|
||||
num_predict: options?.maxTokens || this.config.maxTokens || 4096,
|
||||
temperature: options?.temperature ?? this.config.temperature ?? 0.7,
|
||||
},
|
||||
};
|
||||
// Inject the Arsenal contract as a system turn when tools are offered, and
|
||||
// sanitize prior tool-request / tool-result turns — a plain local model doesn't
|
||||
// understand role:'tool', and re-emitting a prior ```json``` block would get
|
||||
// re-parsed as a fresh live call (defeats termination). Its TEXT reply is parsed
|
||||
// back into tool calls after the round-trip.
|
||||
private buildMessages(messages: LLMMessage[], options?: ChatOptions): { role: string; content: string }[] {
|
||||
const contract = renderToolContract(options?.tools);
|
||||
const preamble = 'You are the planning brain for T3MP3ST, an authorized offensive-security harness.'
|
||||
+ (contract
|
||||
? ' You drive a ReAct loop: REQUEST tools, the harness runs them and returns results, you reason until the surface is exhausted.' + contract
|
||||
: ' Operate in planning and analysis mode; when JSON is requested, return ONLY the requested block — no preamble.');
|
||||
const out: { role: string; content: string }[] = [{ role: 'system', content: preamble }];
|
||||
for (const m of messages) {
|
||||
if (m.role === 'assistant' && m.toolCalls?.length) {
|
||||
out.push({ role: 'assistant', content: `[requested tools: ${m.toolCalls.map(t => t.name).join(', ')}]` });
|
||||
} else if (m.role === 'tool') {
|
||||
out.push({ role: 'user', content: `TOOL RESULT (${m.name || 'tool'}):\n${m.content}` });
|
||||
} else {
|
||||
out.push({ role: m.role, content: m.content });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
|
||||
const baseUrl = (this.config.baseUrl || 'http://localhost:11434/api').replace(/\/$/, '');
|
||||
const openaiWire = this.isOpenAIWire(baseUrl);
|
||||
const url = `${baseUrl}/${openaiWire ? 'chat/completions' : 'chat'}`;
|
||||
const wireMessages = this.buildMessages(messages, options);
|
||||
const maxTokens = options?.maxTokens || this.config.maxTokens || 4096;
|
||||
const temperature = options?.temperature ?? this.config.temperature ?? 0.7;
|
||||
|
||||
const requestBody = openaiWire
|
||||
? { model: this.config.model, messages: wireMessages, max_tokens: maxTokens, temperature, stream: false }
|
||||
: { model: this.config.model, messages: wireMessages, stream: false, options: { num_predict: maxTokens, temperature } };
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Local OpenAI-compatible servers usually ignore auth, but LM Studio & co.
|
||||
// expect *some* bearer — send a dummy unless the operator set a real key.
|
||||
...(openaiWire ? { Authorization: `Bearer ${this.config.apiKey || 'local'}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(this.config.timeout || 120000),
|
||||
@@ -748,24 +783,35 @@ class LocalAdapter implements LLMProviderAdapter {
|
||||
throw new Error(`Local LLM error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as OllamaResponse;
|
||||
const data = await response.json() as OllamaResponse & {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
};
|
||||
const content = openaiWire ? (data.choices?.[0]?.message?.content || '') : (data.message?.content || '');
|
||||
|
||||
// Tool-calling over text: if the Arsenal was offered, parse the model's tool
|
||||
// requests so the ReAct loop EXECUTES them instead of abstaining on turn 0.
|
||||
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
|
||||
|
||||
const usage = openaiWire
|
||||
? (data.usage
|
||||
? { promptTokens: data.usage.prompt_tokens, completionTokens: data.usage.completion_tokens, totalTokens: data.usage.total_tokens }
|
||||
: undefined)
|
||||
: (data.eval_count
|
||||
? { promptTokens: data.prompt_eval_count || 0, completionTokens: data.eval_count || 0, totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0) }
|
||||
: undefined);
|
||||
|
||||
return {
|
||||
content: data.message?.content || '',
|
||||
content,
|
||||
model: data.model || this.config.model,
|
||||
usage: data.eval_count
|
||||
? {
|
||||
promptTokens: data.prompt_eval_count || 0,
|
||||
completionTokens: data.eval_count || 0,
|
||||
totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
|
||||
}
|
||||
: undefined,
|
||||
finishReason: 'stop',
|
||||
usage,
|
||||
finishReason: toolCalls?.length ? 'tool_calls' : 'stop',
|
||||
toolCalls,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
throw new Error(
|
||||
'Could not connect to local LLM. Make sure Ollama is running (ollama serve)'
|
||||
'Could not connect to local LLM. Start Ollama (`ollama serve`), or point TEMPEST_LOCAL_BASE_URL at your OpenAI-compatible server (LM Studio / vLLM / llama.cpp).'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
|
||||
Reference in new issue
Block a user