mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-14 10:24:30 +00:00
Merge pull request #951 from kaitranntt/kai/feat/765-huggingface-inference-providers
feat: add Hugging Face API profile preset
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -265,11 +265,16 @@ async function resolveModelConfiguration(
|
||||
}
|
||||
|
||||
async function resolveDefaultTarget(
|
||||
preset: ProviderPreset | null,
|
||||
providedTarget: TargetType | undefined,
|
||||
yes: boolean | undefined
|
||||
): Promise<TargetType> {
|
||||
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<ProviderPreset, 'defaultTarget'> | 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<void> {
|
||||
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<void> {
|
||||
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('');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** Enable always thinking mode. */
|
||||
@@ -57,6 +60,7 @@ export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>>
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,7 @@ export function ProfileCreateDialog({
|
||||
opusModel: preset.defaultModel,
|
||||
sonnetModel: preset.defaultModel,
|
||||
haikuModel: preset.defaultModel,
|
||||
target: preset.defaultTarget ?? 'claude',
|
||||
});
|
||||
},
|
||||
[reset]
|
||||
|
||||
@@ -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(
|
||||
<ProfileCreateDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
initialMode="openrouter"
|
||||
/>
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user