From 83255f477197c9f74b472b36d9a0086030c10713 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:29:25 -0400 Subject: [PATCH 1/3] feat(api): add Hugging Face provider preset - add the huggingface preset and hf alias to the shared preset catalog - default the preset to Factory Droid because the documented path is generic chat completions - cover the CLI and dialog behavior with preset metadata and UI tests --- src/commands/api-command/create-command.ts | 9 +++++-- src/commands/api-command/help.ts | 3 +++ src/shared/provider-preset-catalog.ts | 18 ++++++++++++++ tests/unit/api/provider-presets.test.ts | 19 +++++++++++++++ .../profiles/profile-create-dialog.tsx | 1 + .../profiles/profile-create-dialog.test.tsx | 24 +++++++++++++++++++ ui/tests/unit/ui/lib/provider-presets.test.ts | 7 ++++++ 7 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index ccdbe5e6..063acf55 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -265,12 +265,17 @@ async function resolveModelConfiguration( } async function resolveDefaultTarget( + preset: ProviderPreset | null, providedTarget: TargetType | undefined, yes: boolean | undefined ): Promise { if (providedTarget) { return providedTarget; } + if (preset?.defaultTarget) { + console.log(info(`Using preset default target: ${preset.defaultTarget}`)); + return preset.defaultTarget; + } if (yes) { return 'claude'; } @@ -349,7 +354,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { parsedArgs.name, parsedArgs.yes ); - const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes); if (name && apiProfileExists(name) && !parsedArgs.force) { console.log(fail(`API '${name}' already exists`)); @@ -464,7 +469,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { const finalModels = hasClaudeMappings ? applyClaudeExtendedContextPreference(models, shouldEnableClaudeLongContext) : models; - const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + const target = await resolveDefaultTarget(preset, parsedArgs.target, parsedArgs.yes); if (parsedArgs.extendedContext !== undefined && !hasClaudeMappings) { console.log(''); diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index 78c6dcc4..000a1840 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -91,6 +91,9 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P writeLine(` ${color('ccs api create --preset openrouter', 'command')}`); writeLine(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); writeLine(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); + writeLine( + ` ${color('ccs api create hf-router --preset hf', 'command')} ${dim('# defaults to droid for generic chat completions')}` + ); writeLine(` ${color('ccs api create --preset glm', 'command')}`); writeLine(''); writeLine(subheader('Claude Long Context')); diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 0baa2270..fa39b1e8 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -6,10 +6,12 @@ */ export type PresetCategory = 'recommended' | 'alternative'; +export type ProviderPresetTarget = 'claude' | 'droid' | 'codex'; export const PROVIDER_PRESET_IDS = [ 'openrouter', 'alibaba-coding-plan', + 'huggingface', 'ollama', 'llamacpp', 'anthropic', @@ -36,6 +38,7 @@ export interface ProviderPresetDefinition { apiKeyHint: string; category: PresetCategory; requiresApiKey: boolean; + defaultTarget?: ProviderPresetTarget; /** Additional env vars for thinking mode, etc. */ extraEnv?: Record; /** Enable always thinking mode. */ @@ -57,6 +60,7 @@ export const PROVIDER_PRESET_ALIASES: Readonly> kimi: 'km', alibaba: 'alibaba-coding-plan', acp: 'alibaba-coding-plan', + hf: 'huggingface', }); const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ @@ -135,6 +139,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ featured: true, icon: '/assets/providers/claude.svg', }, + { + id: 'huggingface', + name: 'Hugging Face', + description: 'Inference Providers router via OpenAI-compatible chat completions', + baseUrl: 'https://router.huggingface.co/v1', + defaultProfileName: 'hf', + defaultModel: 'openai/gpt-oss-120b:fastest', + apiKeyPlaceholder: 'hf_...', + apiKeyHint: 'Create a User Access Token at hf.co/settings/tokens', + category: 'alternative', + requiresApiKey: true, + defaultTarget: 'droid', + badge: 'Router', + }, { id: 'glm', name: 'GLM', diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 7a233f27..e672b496 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -73,6 +73,25 @@ describe('provider-presets', () => { expect(preset?.defaultProfileName).toBe('qwen-api'); }); + it('resolves Hugging Face preset metadata', () => { + const preset = getPresetById('huggingface'); + expect(preset?.id).toBe('huggingface'); + expect(preset?.baseUrl).toBe('https://router.huggingface.co/v1'); + expect(preset?.defaultProfileName).toBe('hf'); + expect(preset?.defaultModel).toBe('openai/gpt-oss-120b:fastest'); + expect(preset?.defaultTarget).toBe('droid'); + expect(preset?.apiKeyPlaceholder).toBe('hf_...'); + }); + + it('resolves hf alias to the Hugging Face preset', () => { + const preset = getPresetById('hf'); + expect(preset?.id).toBe('huggingface'); + }); + + it('treats hf alias as a valid preset id', () => { + expect(isValidPresetId('hf')).toBe(true); + }); + it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { const recommendedPresetIds = PROVIDER_PRESETS.filter( (preset) => preset.category === 'recommended' diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index eabf55f6..1ae37568 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -157,6 +157,7 @@ export function ProfileCreateDialog({ opusModel: preset.defaultModel, sonnetModel: preset.defaultModel, haikuModel: preset.defaultModel, + target: preset.defaultTarget ?? 'claude', }); }, [reset] diff --git a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx index 2919fe84..258df382 100644 --- a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx +++ b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx @@ -47,6 +47,7 @@ describe('ProfileCreateDialog', () => { expect(screen.getByText('More Presets')).toBeInTheDocument(); expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); expect(screen.getByText('Alibaba Coding Plan')).toBeVisible(); + expect(screen.getByText('Hugging Face')).toBeVisible(); expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2); const customButton = screen.getByRole('button', { name: /Custom Endpoint/i }); @@ -74,4 +75,27 @@ describe('ProfileCreateDialog', () => { expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument(); expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); }); + + it('steers the Hugging Face preset to the droid target by default', async () => { + render( + + ); + + const huggingFaceButton = screen.getByText('Hugging Face').closest('button'); + expect(huggingFaceButton).not.toBeNull(); + if (!huggingFaceButton) { + throw new Error('Hugging Face preset button not found'); + } + + await userEvent.click(huggingFaceButton); + + expect(screen.getByDisplayValue('hf')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://router.huggingface.co/v1')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveTextContent('Factory Droid'); + }); }); diff --git a/ui/tests/unit/ui/lib/provider-presets.test.ts b/ui/tests/unit/ui/lib/provider-presets.test.ts index bac35a94..356155f5 100644 --- a/ui/tests/unit/ui/lib/provider-presets.test.ts +++ b/ui/tests/unit/ui/lib/provider-presets.test.ts @@ -32,6 +32,13 @@ describe('provider preset metadata', () => { expect(getPresetById('glmt')?.id).toBe('glm'); }); + it('maps hf alias to the Hugging Face preset', () => { + const preset = getPresetById('hf'); + expect(preset?.id).toBe('huggingface'); + expect(preset?.baseUrl).toBe('https://router.huggingface.co/v1'); + expect(preset?.defaultTarget).toBe('droid'); + }); + it('uses the llama.cpp provider logo asset for the local llama.cpp preset', () => { expect(getPresetById('llamacpp')?.icon).toBe('/assets/providers/llama-cpp.svg'); }); From 414e8c80f5ed5f4ed91766de85603abaa195bcbf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:29:46 -0400 Subject: [PATCH 2/3] docs(roadmap): record Hugging Face preset support --- docs/project-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 82c63df6..d70ae8e2 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`. - **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. - **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. From 52ac29e7a8a6f7450eb3c1daa357d84d0b03031f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:44:25 -0400 Subject: [PATCH 3/3] fix(api): constrain preset target defaults - limit preset default targets to persisted API profile targets - add regression coverage for preset target resolution and profile target persistence --- src/commands/api-command/create-command.ts | 25 +++++++--- src/shared/provider-preset-catalog.ts | 2 +- .../unit/api/profile-writer-anthropic.test.ts | 46 +++++++++++++++++++ .../api-create-target-resolution.test.ts | 17 +++++++ 4 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 tests/unit/commands/api-create-target-resolution.test.ts diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 063acf55..1909c2dc 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -269,12 +269,12 @@ async function resolveDefaultTarget( providedTarget: TargetType | undefined, yes: boolean | undefined ): Promise { - if (providedTarget) { - return providedTarget; - } - if (preset?.defaultTarget) { - console.log(info(`Using preset default target: ${preset.defaultTarget}`)); - return preset.defaultTarget; + const resolvedTarget = resolvePresetDefaultTarget(preset, providedTarget); + if (resolvedTarget) { + if (preset?.defaultTarget && !providedTarget) { + console.log(info(`Using preset default target: ${preset.defaultTarget}`)); + } + return resolvedTarget; } if (yes) { return 'claude'; @@ -287,6 +287,19 @@ async function resolveDefaultTarget( return useDroidByDefault ? 'droid' : 'claude'; } +export function resolvePresetDefaultTarget( + preset: Pick | null, + providedTarget: TargetType | undefined +): TargetType | null { + if (providedTarget) { + return providedTarget; + } + if (preset?.defaultTarget) { + return preset.defaultTarget; + } + return null; +} + async function resolveClaudeLongContextPreference( models: ModelMapping, explicitPreference: boolean | undefined, diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index fa39b1e8..b94fe6e8 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -6,7 +6,7 @@ */ export type PresetCategory = 'recommended' | 'alternative'; -export type ProviderPresetTarget = 'claude' | 'droid' | 'codex'; +export type ProviderPresetTarget = 'claude' | 'droid'; export const PROVIDER_PRESET_IDS = [ 'openrouter', diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 6eab981b..0416c321 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -85,6 +85,52 @@ describe('profile-writer Anthropic direct', () => { expect(settings.env.ANTHROPIC_API_KEY).toBeUndefined(); }); + it('persists droid as the saved target for generic API profiles', () => { + const result = createApiProfile( + 'hf-target', + 'https://router.huggingface.co/v1', + 'hf_testkey123', + { + default: 'openai/gpt-oss-120b:fastest', + opus: 'openai/gpt-oss-120b:fastest', + sonnet: 'openai/gpt-oss-120b:fastest', + haiku: 'openai/gpt-oss-120b:fastest', + }, + 'droid' + ); + + expect(result.success).toBe(true); + + const configPath = path.join(tempHome, '.ccs', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + expect(config.profiles['hf-target']).toBe('~/.ccs/hf-target.settings.json'); + expect(config.profile_targets['hf-target']).toBe('droid'); + }); + + it('does not persist a non-default target entry when the target is claude', () => { + const result = createApiProfile( + 'hf-target-claude', + 'https://router.huggingface.co/v1', + 'hf_testkey123', + { + default: 'openai/gpt-oss-120b:fastest', + opus: 'openai/gpt-oss-120b:fastest', + sonnet: 'openai/gpt-oss-120b:fastest', + haiku: 'openai/gpt-oss-120b:fastest', + }, + 'claude' + ); + + expect(result.success).toBe(true); + + const configPath = path.join(tempHome, '.ccs', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + expect(config.profiles['hf-target-claude']).toBe('~/.ccs/hf-target-claude.settings.json'); + expect(config.profile_targets?.['hf-target-claude']).toBeUndefined(); + }); + it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { const result = createApiProfile( 'openrouter-test', diff --git a/tests/unit/commands/api-create-target-resolution.test.ts b/tests/unit/commands/api-create-target-resolution.test.ts new file mode 100644 index 00000000..eab0437f --- /dev/null +++ b/tests/unit/commands/api-create-target-resolution.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test'; + +import { resolvePresetDefaultTarget } from '../../../src/commands/api-command/create-command'; + +describe('api create target resolution', () => { + it('uses the preset default target when no explicit target is provided', () => { + expect(resolvePresetDefaultTarget({ defaultTarget: 'droid' }, undefined)).toBe('droid'); + }); + + it('lets an explicit target override the preset default target', () => { + expect(resolvePresetDefaultTarget({ defaultTarget: 'droid' }, 'claude')).toBe('claude'); + }); + + it('returns null when neither an explicit target nor a preset default exists', () => { + expect(resolvePresetDefaultTarget(null, undefined)).toBeNull(); + }); +});