feat(local-models): self-hosted backbone runs the Arsenal (Ollama + OpenAI-compatible)

This commit is contained in:
t3mp3st contributors committed 2026-07-05 17:25:50 -04:00
1 parent 1e20ce1c03
commit eac30e4ee3
5 files changed
+136 -35

No files matched your search

+38 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;