diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index b25ed9af..4f977fdd 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**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`. - **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. diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index ccdbe5e6..1909c2dc 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -265,11 +265,16 @@ async function resolveModelConfiguration( } async function resolveDefaultTarget( + preset: ProviderPreset | null, providedTarget: TargetType | undefined, yes: boolean | undefined ): Promise { - if (providedTarget) { - return providedTarget; + 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'; @@ -282,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, @@ -349,7 +367,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 +482,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..b94fe6e8 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'; 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/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/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/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(); + }); +}); 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'); });