mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-13 06:22:49 +00:00
Merge pull request #693 from jellydn/feat/llamacpp-support
feat(api): add llama.cpp support as local model provider
This commit is contained in:
@@ -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).
|
||||
|
||||

|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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-...)'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user