Merge pull request #693 from jellydn/feat/llamacpp-support

feat(api): add llama.cpp support as local model provider
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-09 05:24:46 -04:00
committed by GitHub
10 changed files with 103 additions and 30 deletions
+4
View File
@@ -102,6 +102,7 @@ The dashboard provides visual management for all account types:
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
| **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API |
| **Ollama** | Local | `ccs ollama` | Local open-source models, privacy |
| **llama.cpp** | Local | `ccs llamacpp` | Local GGUF inference via llama.cpp server |
| **Ollama Cloud** | API Key | `ccs ollama-cloud` | Cloud-hosted open-source models |
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
| **KM (Kimi API)** | API Key | `ccs km` | Long-context, thinking mode |
@@ -119,6 +120,8 @@ The dashboard provides visual management for all account types:
**Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`.
**llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port.
**Azure Foundry**: Use `ccs api create --preset foundry` to set up Claude via Microsoft Azure AI Foundry. Requires Azure resource and API key from [ai.azure.com](https://ai.azure.com).
![OpenRouter API Profiles](assets/screenshots/api-profiles-openrouter.webp)
@@ -149,6 +152,7 @@ ccs ghcp # GitHub Copilot (OAuth device flow)
ccs agy # Antigravity (OAuth)
ccs qwen # Qwen Code (OAuth via CLIProxy)
ccs ollama # Local Ollama (no API key needed)
ccs llamacpp # Local llama.cpp (no API key needed)
ccs glm # GLM (API key)
ccs km # Kimi API profile (API key)
ccs api create --preset alibaba-coding-plan # Alibaba Coding Plan profile
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8080",
"ANTHROPIC_AUTH_TOKEN": "llamacpp",
"ANTHROPIC_MODEL": "llama3-8b",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "llama3-70b",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "llama3-8b",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "llama3-2b"
}
}
+7 -6
View File
@@ -372,15 +372,16 @@ async function handleCreate(args: string[]): Promise<void> {
let apiKey = parsedArgs.apiKey;
if (preset?.requiresApiKey === false) {
// Preset doesn't require API key (e.g., local Ollama)
const presetLabel = preset.name;
const optionalApiKey = preset.apiKeyPlaceholder || preset.id;
if (parsedArgs.apiKey) {
console.log(dim('Note: Using provided API key for local Ollama (optional)'));
console.log(dim(`Note: Using provided API key for ${presetLabel} (optional)`));
apiKey = parsedArgs.apiKey;
} else {
console.log(info('No API key required for local Ollama'));
// Sentinel value 'ollama' matches config/base-ollama.settings.json template
// This is not a valid API key, just a placeholder for local-only providers
apiKey = 'ollama';
console.log(info(`No API key required for ${presetLabel}`));
// Local providers still need a truthy auth token persisted in settings.json.
apiKey = optionalApiKey;
}
} else if (!apiKey) {
const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key';
+1
View File
@@ -138,6 +138,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'Alibaba Coding Plan (Anthropic-compatible API key)',
],
['ccs ollama', 'Local Ollama (http://localhost:11434)'],
['ccs llamacpp', 'Local llama.cpp (http://127.0.0.1:8080)'],
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
['', ''], // Spacer
['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'],
+15
View File
@@ -12,6 +12,7 @@ export const PROVIDER_PRESET_IDS = [
'openrouter',
'alibaba-coding-plan',
'ollama',
'llamacpp',
'glm',
'glmt',
'km',
@@ -117,6 +118,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
featured: true,
icon: '/icons/ollama.svg',
},
{
id: 'llamacpp',
name: 'llama.cpp (Local)',
description: 'Local inference via llama.cpp (LLaMA models)',
baseUrl: 'http://127.0.0.1:8080',
defaultProfileName: 'llamacpp',
defaultModel: 'llama3-8b',
apiKeyPlaceholder: 'llamacpp',
apiKeyHint: 'Run llama.cpp server: ./server --host 0.0.0.0 --port 8080 -m model.gguf',
category: 'recommended',
requiresApiKey: false,
badge: 'Local',
featured: true,
},
{
id: 'glm',
name: 'GLM',
+8
View File
@@ -23,6 +23,14 @@ describe('provider-presets', () => {
expect(preset?.id).toBe('km');
});
it('resolves llama.cpp preset with local-provider sentinel token', () => {
const preset = getPresetById('llamacpp');
expect(preset?.id).toBe('llamacpp');
expect(preset?.requiresApiKey).toBe(false);
expect(preset?.apiKeyPlaceholder).toBe('llamacpp');
expect(preset?.baseUrl).toBe('http://127.0.0.1:8080');
});
it('resolves legacy kimi preset alias to km', () => {
const preset = getPresetById('kimi');
expect(preset?.id).toBe('km');
@@ -25,5 +25,19 @@ describe('help command parity', () => {
expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false);
expect(rendered.includes('ccs cliproxy status')).toBe(true);
expect(rendered.includes('ccs cliproxy quota --provider <name>')).toBe(true);
expect(rendered.includes('ccs llamacpp')).toBe(true);
});
test('root help documents llama.cpp as a local API profile', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs llamacpp')).toBe(true);
expect(rendered.includes('http://127.0.0.1:8080')).toBe(true);
});
});
@@ -38,6 +38,7 @@ import {
PROVIDER_PRESETS,
getPresetsByCategory,
getPresetById,
resolvePresetApiKeyValue,
type ProviderPreset,
} from '@/lib/provider-presets';
import {
@@ -257,8 +258,7 @@ export function ProfileCreateDialog({
// Use user-provided baseUrl (allows customization of preset URLs)
const finalData = {
...data,
// Use provided API key, or empty string if not provided (for optional auth providers)
apiKey: data.apiKey || '',
apiKey: resolvePresetApiKeyValue(currentPreset, data.apiKey),
};
try {
await createMutation.mutateAsync(finalData);
@@ -459,7 +459,7 @@ export function ProfileCreateDialog({
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : currentPreset?.requiresApiKey === false ? (
<p className="text-xs text-muted-foreground">
Only needed if you have configured Ollama authentication
Only needed if your local endpoint has authentication enabled
</p>
) : (
currentPreset?.apiKeyHint && (
+17 -21
View File
@@ -1,6 +1,6 @@
/**
* Provider Presets Configuration
* Shared catalog from backend source-of-truth with UI-only presentation overrides.
* Shared catalog from backend source-of-truth.
*/
import {
@@ -9,7 +9,6 @@ import {
normalizeProviderPresetId,
type PresetCategory,
type ProviderPresetDefinition,
type ProviderPresetId,
} from '../../../src/shared/provider-preset-catalog';
export { OPENROUTER_BASE_URL };
@@ -17,30 +16,27 @@ export type { PresetCategory };
export type ProviderPreset = ProviderPresetDefinition;
/**
* UI-only overrides for presentation details that differ from CLI semantics.
* Keep this tiny; provider data itself belongs in shared catalog.
*/
type UiPresetOverride = Pick<ProviderPreset, 'apiKeyPlaceholder'>;
const UI_PRESET_OVERRIDES: Readonly<Partial<Record<ProviderPresetId, UiPresetOverride>>> =
Object.freeze({
ollama: {
apiKeyPlaceholder: '',
},
});
function withUiOverrides(preset: ProviderPresetDefinition): ProviderPreset {
const overrides = UI_PRESET_OVERRIDES[preset.id];
return overrides ? { ...preset, ...overrides } : { ...preset };
}
const BASE_PROVIDER_PRESETS = createProviderPresetDefinitions();
export const PROVIDER_PRESETS: readonly ProviderPreset[] = Object.freeze(
BASE_PROVIDER_PRESETS.map(withUiOverrides)
BASE_PROVIDER_PRESETS.map((preset) => ({ ...preset }))
);
export function resolvePresetApiKeyValue(
preset: ProviderPreset | null | undefined,
apiKey: string
): string {
if (apiKey) {
return apiKey;
}
if (preset?.requiresApiKey === false) {
return preset.apiKeyPlaceholder || preset.id;
}
return '';
}
/** Get presets by category */
export function getPresetsByCategory(category: PresetCategory): ProviderPreset[] {
return PROVIDER_PRESETS.filter((preset) => preset.category === category);
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getPresetById, resolvePresetApiKeyValue } from '@/lib/provider-presets';
describe('resolvePresetApiKeyValue', () => {
it('keeps an explicit API key when one is provided', () => {
const preset = getPresetById('llamacpp');
expect(resolvePresetApiKeyValue(preset, 'custom-token')).toBe('custom-token');
});
it('uses the local-provider sentinel for Ollama when no API key is provided', () => {
const preset = getPresetById('ollama');
expect(resolvePresetApiKeyValue(preset, '')).toBe('ollama');
});
it('uses the local-provider sentinel for llama.cpp when no API key is provided', () => {
const preset = getPresetById('llamacpp');
expect(resolvePresetApiKeyValue(preset, '')).toBe('llamacpp');
});
it('returns an empty string for API-key providers when input is empty', () => {
const preset = getPresetById('openrouter');
expect(resolvePresetApiKeyValue(preset, '')).toBe('');
});
});