Skip to content

Commit 7b22f6d

Browse files
committed
feat(webuiapps): add local llama.cpp LLM support
1 parent add231b commit 7b22f6d

4 files changed

Lines changed: 87 additions & 22 deletions

File tree

apps/webuiapps/src/components/ChatPanel/index.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ interface CharacterDisplayMessage extends DisplayMessage {
9595
toolCalls?: string[]; // collapsed tool call summaries
9696
}
9797

98+
function hasUsableLLMConfig(config: LLMConfig | null | undefined): config is LLMConfig {
99+
return !!config?.baseUrl.trim() && !!config.model.trim();
100+
}
101+
98102
// ---------------------------------------------------------------------------
99103
// Tool definitions for character system
100104
// ---------------------------------------------------------------------------
@@ -649,7 +653,7 @@ const ChatPanel: React.FC<{
649653
while (actionQueueRef.current.length > 0) {
650654
const actionMsg = actionQueueRef.current.shift()!;
651655
const cfg = configRef.current;
652-
if (!cfg?.apiKey) break;
656+
if (!hasUsableLLMConfig(cfg)) break;
653657

654658
const newHistory: ChatMessage[] = [
655659
...chatHistoryRef.current,
@@ -672,7 +676,7 @@ const ChatPanel: React.FC<{
672676
useEffect(() => {
673677
const unsubscribe = onUserAction((event: unknown) => {
674678
const cfg = configRef.current;
675-
if (!cfg?.apiKey) return;
679+
if (!hasUsableLLMConfig(cfg)) return;
676680

677681
const evt = event as {
678682
app_action?: {
@@ -704,7 +708,7 @@ const ChatPanel: React.FC<{
704708
async (overrideText?: string) => {
705709
const text = overrideText ?? input.trim();
706710
if (!text || loading) return;
707-
if (!config?.apiKey) {
711+
if (!hasUsableLLMConfig(config)) {
708712
setShowSettings(true);
709713
return;
710714
}
@@ -1102,9 +1106,9 @@ const ChatPanel: React.FC<{
11021106
<div className={styles.messages} data-testid="chat-messages">
11031107
{messages.length === 0 && (
11041108
<div className={styles.emptyState}>
1105-
{config?.apiKey
1109+
{hasUsableLLMConfig(config)
11061110
? `${character.character_name} is ready to chat...`
1107-
: 'Click the gear icon to configure your LLM API key'}
1111+
: 'Click the gear icon to configure your LLM connection'}
11081112
</div>
11091113
)}
11101114
{messages.map((msg) => (
@@ -1287,6 +1291,7 @@ const SettingsModal: React.FC<{
12871291
<option value="openai">OpenAI</option>
12881292
<option value="anthropic">Anthropic</option>
12891293
<option value="deepseek">DeepSeek</option>
1294+
<option value="llama.cpp">llama.cpp</option>
12901295
<option value="minimax">MiniMax</option>
12911296
<option value="z.ai">Z.ai</option>
12921297
<option value="kimi">Kimi</option>
@@ -1301,7 +1306,7 @@ const SettingsModal: React.FC<{
13011306
type="password"
13021307
value={apiKey}
13031308
onChange={(e) => setApiKey(e.target.value)}
1304-
placeholder="sk-..."
1309+
placeholder="Optional for local servers"
13051310
/>
13061311
</div>
13071312

apps/webuiapps/src/lib/__tests__/llmClient.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const MOCK_ANTHROPIC_CONFIG: LLMConfig = {
3636
model: 'claude-opus-4-6',
3737
};
3838

39+
const MOCK_LLAMACPP_CONFIG: LLMConfig = {
40+
provider: 'llama.cpp',
41+
apiKey: '',
42+
baseUrl: 'http://athena:8081',
43+
model: 'Qwen_Qwen3.5-35B-A3B',
44+
};
45+
3946
const MOCK_MESSAGES: ChatMessage[] = [{ role: 'user', content: 'Hello' }];
4047

4148
const MOCK_TOOLS: ToolDef[] = [
@@ -114,6 +121,13 @@ describe('getDefaultProviderConfig()', () => {
114121
expect(cfg.model).toBe('deepseek-chat');
115122
});
116123

124+
it('returns correct defaults for llama.cpp', () => {
125+
const cfg = getDefaultProviderConfig('llama.cpp');
126+
expect(cfg.provider).toBe('llama.cpp');
127+
expect(cfg.baseUrl).toBe('http://localhost:8080');
128+
expect(cfg.model).toBe('local-model');
129+
});
130+
117131
it('returns correct defaults for minimax', () => {
118132
const cfg = getDefaultProviderConfig('minimax');
119133
expect(cfg.provider).toBe('minimax');
@@ -422,6 +436,31 @@ describe('chat()', () => {
422436
});
423437
});
424438

439+
describe('llama.cpp provider (OpenAI-compatible)', () => {
440+
it('routes to OpenAI path without requiring an API key', async () => {
441+
const mockFetch = vi.fn().mockResolvedValueOnce(makeOpenAIResponse('Local response'));
442+
globalThis.fetch = mockFetch;
443+
444+
const result = await chat(MOCK_MESSAGES, [], MOCK_LLAMACPP_CONFIG);
445+
446+
expect(result.content).toBe('Local response');
447+
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
448+
expect(headers['Authorization']).toBeUndefined();
449+
expect(headers['X-LLM-Target-URL']).toBe('http://athena:8081/v1/chat/completions');
450+
});
451+
452+
it('strips Qwen-style think tags from assistant content', async () => {
453+
const mockFetch = vi
454+
.fn()
455+
.mockResolvedValueOnce(makeOpenAIResponse('<think>hidden reasoning</think>Hello there'));
456+
globalThis.fetch = mockFetch;
457+
458+
const result = await chat(MOCK_MESSAGES, [], MOCK_LLAMACPP_CONFIG);
459+
460+
expect(result.content).toBe('Hello there');
461+
});
462+
});
463+
425464
describe('Anthropic provider', () => {
426465
it('uses x-api-key and anthropic-version headers', async () => {
427466
const mockFetch = vi.fn().mockResolvedValueOnce(makeAnthropicResponse('Anthropic response'));

apps/webuiapps/src/lib/llmClient.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Minimal LLM API Client
3-
* Supports OpenAI / DeepSeek / Anthropic formats
3+
* Supports OpenAI-compatible / Anthropic-compatible formats
44
*/
55

66
import type { LLMConfig } from './llmModels';
@@ -88,6 +88,13 @@ interface LLMResponse {
8888
toolCalls: ToolCall[];
8989
}
9090

91+
function stripThinkTags(content: string): string {
92+
const withoutBlocks = content
93+
.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '')
94+
.replace(/<\/?think\b[^>]*>/gi, '');
95+
return withoutBlocks === content ? content : withoutBlocks.trim();
96+
}
97+
9198
function hasVersionSuffix(url: string): boolean {
9299
return /\/v\d+\/?$/.test(url);
93100
}
@@ -162,14 +169,17 @@ async function chatOpenAI(
162169
messageCount: messages.length,
163170
toolCount: tools.length,
164171
});
172+
const headers: Record<string, string> = {
173+
'Content-Type': 'application/json',
174+
'X-LLM-Target-URL': targetUrl,
175+
...parseCustomHeaders(config.customHeaders),
176+
};
177+
if (config.apiKey.trim()) {
178+
headers.Authorization = `Bearer ${config.apiKey}`;
179+
}
165180
const res = await fetch('/api/llm-proxy', {
166181
method: 'POST',
167-
headers: {
168-
'Content-Type': 'application/json',
169-
Authorization: `Bearer ${config.apiKey}`,
170-
'X-LLM-Target-URL': targetUrl,
171-
...parseCustomHeaders(config.customHeaders),
172-
},
182+
headers,
173183
body: JSON.stringify(body),
174184
});
175185

@@ -195,7 +205,7 @@ async function chatOpenAI(
195205
calledNames,
196206
);
197207
return {
198-
content: choice?.content || '',
208+
content: stripThinkTags(choice?.content || ''),
199209
toolCalls,
200210
};
201211
}
@@ -267,15 +277,18 @@ async function chatAnthropic(
267277
messageCount: anthropicMessages.length,
268278
toolCount: anthropicTools.length,
269279
});
280+
const headers: Record<string, string> = {
281+
'Content-Type': 'application/json',
282+
'anthropic-version': '2023-06-01',
283+
'X-LLM-Target-URL': targetUrl,
284+
...parseCustomHeaders(config.customHeaders),
285+
};
286+
if (config.apiKey.trim()) {
287+
headers['x-api-key'] = config.apiKey;
288+
}
270289
const res = await fetch('/api/llm-proxy', {
271290
method: 'POST',
272-
headers: {
273-
'Content-Type': 'application/json',
274-
'x-api-key': config.apiKey,
275-
'anthropic-version': '2023-06-01',
276-
'X-LLM-Target-URL': targetUrl,
277-
...parseCustomHeaders(config.customHeaders),
278-
},
291+
headers,
279292
body: JSON.stringify(body),
280293
});
281294

@@ -314,5 +327,5 @@ async function chatAnthropic(
314327
'calledNames=',
315328
calledNames,
316329
);
317-
return { content, toolCalls };
330+
return { content: stripThinkTags(content), toolCalls };
318331
}

apps/webuiapps/src/lib/llmModels.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type LLMProvider =
22
| 'openai'
33
| 'anthropic'
44
| 'deepseek'
5+
| 'llama.cpp'
56
| 'minimax'
67
| 'z.ai'
78
| 'kimi'
@@ -77,6 +78,13 @@ export const LLM_PROVIDER_CONFIGS: Record<LLMProvider, ProviderModelConfig> = {
7778
],
7879
},
7980

81+
'llama.cpp': {
82+
displayName: 'llama.cpp',
83+
baseUrl: 'http://localhost:8080',
84+
defaultModel: 'local-model',
85+
models: [],
86+
},
87+
8088
minimax: {
8189
displayName: 'MiniMax',
8290
baseUrl: 'https://api.minimax.io/anthropic/v1',

0 commit comments

Comments
 (0)