diff --git a/README.md b/README.md index 1b36e316..366d4df2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/base-llamacpp.settings.json b/config/base-llamacpp.settings.json new file mode 100644 index 00000000..48de05dc --- /dev/null +++ b/config/base-llamacpp.settings.json @@ -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" + } +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 82903383..535e2eb7 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -372,15 +372,16 @@ async function handleCreate(args: string[]): Promise { 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'; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 460ba555..cbe44ebb 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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-...)'], diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 804cbea8..a1704d0d 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -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', diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 1235088f..2246ed6e 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -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'); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 2aebfc8b..285e6585 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -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 ')).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); }); }); diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index f8993fb3..5face9d6 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -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({

{errors.apiKey.message}

) : currentPreset?.requiresApiKey === false ? (

- Only needed if you have configured Ollama authentication + Only needed if your local endpoint has authentication enabled

) : ( currentPreset?.apiKeyHint && ( diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts index 60d37633..3ea8bf11 100644 --- a/ui/src/lib/provider-presets.ts +++ b/ui/src/lib/provider-presets.ts @@ -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; - -const UI_PRESET_OVERRIDES: Readonly>> = - 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); diff --git a/ui/tests/unit/ui/lib/provider-presets.test.ts b/ui/tests/unit/ui/lib/provider-presets.test.ts new file mode 100644 index 00000000..5dba0824 --- /dev/null +++ b/ui/tests/unit/ui/lib/provider-presets.test.ts @@ -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(''); + }); +});