mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
feat(droid): align BYOK provider mapping for ccsd
- add Droid provider resolver aligned with Factory BYOK schema - persist and normalize CCS_DROID_PROVIDER across CLI and dashboard flows - use Droid managed selector aliases with safe legacy fallback - add unit coverage for provider inference and selector usage Closes #632
This commit is contained in:
@@ -157,6 +157,11 @@ ccsd glm
|
||||
|
||||
Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`).
|
||||
|
||||
For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint
|
||||
(`CCS_DROID_PROVIDER`) using one of:
|
||||
`anthropic`, `openai`, or `generic-chat-completion-api`.
|
||||
If the hint is missing, CCS resolves provider from base URL/model at runtime.
|
||||
|
||||
### Per-Profile Target Defaults
|
||||
|
||||
You can pin a default target (`claude` or `droid`) per profile:
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../config/unified-config-loader';
|
||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||
import type {
|
||||
ModelMapping,
|
||||
CreateApiProfileResult,
|
||||
@@ -30,10 +31,16 @@ function createSettingsFile(
|
||||
name: string,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
models: ModelMapping
|
||||
models: ModelMapping,
|
||||
provider?: string
|
||||
): string {
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
|
||||
const droidProvider = resolveDroidProvider({
|
||||
provider,
|
||||
baseUrl,
|
||||
model: models.default,
|
||||
});
|
||||
|
||||
const settings = {
|
||||
env: {
|
||||
@@ -43,6 +50,7 @@ function createSettingsFile(
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
CCS_DROID_PROVIDER: droidProvider,
|
||||
// OpenRouter requires explicitly blanking the API key to prevent conflicts
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
},
|
||||
@@ -97,11 +105,17 @@ function createApiProfileUnified(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
models: ModelMapping,
|
||||
target: TargetType = 'claude'
|
||||
target: TargetType = 'claude',
|
||||
provider?: string
|
||||
): void {
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsFile = `${name}.settings.json`;
|
||||
const settingsPath = path.join(ccsDir, settingsFile);
|
||||
const droidProvider = resolveDroidProvider({
|
||||
provider,
|
||||
baseUrl,
|
||||
model: models.default,
|
||||
});
|
||||
|
||||
const settings = {
|
||||
env: {
|
||||
@@ -111,6 +125,7 @@ function createApiProfileUnified(
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
CCS_DROID_PROVIDER: droidProvider,
|
||||
// OpenRouter requires explicitly blanking the API key to prevent conflicts
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
},
|
||||
@@ -140,15 +155,16 @@ export function createApiProfile(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
models: ModelMapping,
|
||||
target: TargetType = 'claude'
|
||||
target: TargetType = 'claude',
|
||||
provider?: string
|
||||
): CreateApiProfileResult {
|
||||
try {
|
||||
const settingsFile = `~/.ccs/${name}.settings.json`;
|
||||
|
||||
if (isUnifiedMode()) {
|
||||
createApiProfileUnified(name, baseUrl, apiKey, models, target);
|
||||
createApiProfileUnified(name, baseUrl, apiKey, models, target, provider);
|
||||
} else {
|
||||
createSettingsFile(name, baseUrl, apiKey, models);
|
||||
createSettingsFile(name, baseUrl, apiKey, models, provider);
|
||||
updateLegacyConfig(name, target);
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -54,6 +54,7 @@ import {
|
||||
ClaudeAdapter,
|
||||
DroidAdapter,
|
||||
pruneOrphanedModels,
|
||||
resolveDroidProvider,
|
||||
type TargetCredentials,
|
||||
} from './targets';
|
||||
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
|
||||
@@ -841,7 +842,11 @@ async function main(): Promise<void> {
|
||||
baseUrl: envVars['ANTHROPIC_BASE_URL'] || '',
|
||||
apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||
model: envVars['ANTHROPIC_MODEL'] || undefined,
|
||||
provider: 'anthropic',
|
||||
provider: resolveDroidProvider({
|
||||
provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'],
|
||||
baseUrl: envVars['ANTHROPIC_BASE_URL'],
|
||||
model: envVars['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
envVars,
|
||||
};
|
||||
|
||||
@@ -1010,6 +1015,11 @@ async function main(): Promise<void> {
|
||||
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
|
||||
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||
provider: resolveDroidProvider({
|
||||
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
|
||||
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'],
|
||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
};
|
||||
await adapter.prepareCredentials(creds);
|
||||
const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs);
|
||||
@@ -1076,6 +1086,11 @@ async function main(): Promise<void> {
|
||||
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
|
||||
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||
model: process.env['ANTHROPIC_MODEL'],
|
||||
provider: resolveDroidProvider({
|
||||
provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'],
|
||||
baseUrl: process.env['ANTHROPIC_BASE_URL'],
|
||||
model: process.env['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
};
|
||||
if (!creds.baseUrl || !creds.apiKey) {
|
||||
console.error(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Droid Adapter
|
||||
*
|
||||
* TargetAdapter implementation for Factory Droid CLI.
|
||||
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-<profile>`.
|
||||
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:<alias>`.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
@@ -11,6 +11,7 @@ import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from '
|
||||
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { upsertCcsModel } from './droid-config-manager';
|
||||
import { resolveDroidProvider } from './droid-provider';
|
||||
import { escapeShellArg } from '../utils/shell-executor';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
import { runCleanup } from '../errors';
|
||||
@@ -18,6 +19,7 @@ import { runCleanup } from '../errors';
|
||||
export class DroidAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'droid';
|
||||
readonly displayName = 'Factory Droid';
|
||||
private readonly modelSelectorsByProfile = new Map<string, string>();
|
||||
|
||||
private validateCredentials(creds: TargetCredentials): void {
|
||||
if (!creds.baseUrl?.trim()) {
|
||||
@@ -43,13 +45,19 @@ export class DroidAdapter implements TargetAdapter {
|
||||
*/
|
||||
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
||||
this.validateCredentials(creds);
|
||||
await upsertCcsModel(creds.profile, {
|
||||
const provider = resolveDroidProvider({
|
||||
provider: creds.provider,
|
||||
baseUrl: creds.baseUrl,
|
||||
model: creds.model,
|
||||
});
|
||||
const modelRef = await upsertCcsModel(creds.profile, {
|
||||
model: creds.model || 'claude-opus-4-6',
|
||||
displayName: `CCS ${creds.profile}`,
|
||||
baseUrl: creds.baseUrl,
|
||||
apiKey: creds.apiKey,
|
||||
provider: creds.provider || 'anthropic',
|
||||
provider,
|
||||
});
|
||||
this.modelSelectorsByProfile.set(creds.profile, modelRef.selector);
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
||||
@@ -58,7 +66,8 @@ export class DroidAdapter implements TargetAdapter {
|
||||
`Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed`
|
||||
);
|
||||
}
|
||||
return ['-m', `custom:ccs-${profile}`, ...userArgs];
|
||||
const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`;
|
||||
return ['-m', selector, ...userArgs];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,14 @@ export interface DroidCustomModel {
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
export interface DroidManagedModelRef {
|
||||
profile: string;
|
||||
displayName: string;
|
||||
index: number;
|
||||
selectorAlias: string;
|
||||
selector: string;
|
||||
}
|
||||
|
||||
interface DroidSettings {
|
||||
customModels?: DroidCustomModelEntry[];
|
||||
[key: string]: unknown;
|
||||
@@ -97,6 +105,11 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null {
|
||||
return isDroidCustomModelEntry(value) ? value : null;
|
||||
}
|
||||
|
||||
function buildSelectorAlias(displayName: string, index: number): string {
|
||||
const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-');
|
||||
return `${normalizedDisplayName}-${index}`;
|
||||
}
|
||||
|
||||
function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -302,11 +315,15 @@ function writeDroidSettings(settings: DroidSettings): void {
|
||||
* Upsert a CCS-managed custom model entry.
|
||||
* Acquires file lock to prevent concurrent write races.
|
||||
*/
|
||||
export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise<void> {
|
||||
export async function upsertCcsModel(
|
||||
profile: string,
|
||||
model: DroidCustomModel
|
||||
): Promise<DroidManagedModelRef> {
|
||||
validateProfileName(profile);
|
||||
ensureFactoryDir();
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
let ref: DroidManagedModelRef | null = null;
|
||||
try {
|
||||
release = await acquireFactoryLock(10);
|
||||
|
||||
@@ -330,9 +347,32 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel):
|
||||
}
|
||||
|
||||
writeDroidSettings(settings);
|
||||
|
||||
const index = settings.customModels.findIndex(
|
||||
(entry) => parseManagedProfile(entry.displayName) === profile
|
||||
);
|
||||
const safeIndex = index >= 0 ? index : 0;
|
||||
const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex);
|
||||
ref = {
|
||||
profile,
|
||||
displayName: entry.displayName,
|
||||
index: safeIndex,
|
||||
selectorAlias,
|
||||
selector: `custom:${selectorAlias}`,
|
||||
};
|
||||
} finally {
|
||||
if (release) await release();
|
||||
}
|
||||
|
||||
return (
|
||||
ref || {
|
||||
profile,
|
||||
displayName: `CCS ${profile}`,
|
||||
index: 0,
|
||||
selectorAlias: `CCS-${profile}-0`,
|
||||
selector: `custom:CCS-${profile}-0`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Droid BYOK provider resolution helpers.
|
||||
*
|
||||
* Factory BYOK accepts exactly:
|
||||
* - anthropic
|
||||
* - openai
|
||||
* - generic-chat-completion-api
|
||||
*
|
||||
* CCS stores provider hints in profile settings as CCS_DROID_PROVIDER and
|
||||
* resolves a best-effort provider from base URL/model when the hint is absent.
|
||||
*/
|
||||
|
||||
export type DroidProvider = 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
||||
|
||||
const GENERIC_PROVIDER_ALIASES = new Set<string>([
|
||||
'generic',
|
||||
'generic-openai',
|
||||
'generic-openai-api',
|
||||
'generic-chat',
|
||||
'generic-chat-completions',
|
||||
'openai-compatible',
|
||||
'chat-completions',
|
||||
]);
|
||||
|
||||
const OPENAI_PROVIDER_ALIASES = new Set<string>(['openai-responses', 'openai-official']);
|
||||
const ANTHROPIC_PROVIDER_ALIASES = new Set<string>(['anthropic-compatible']);
|
||||
|
||||
/**
|
||||
* Normalize potentially messy provider input into a valid Factory provider.
|
||||
*/
|
||||
export function normalizeDroidProvider(provider: string | undefined | null): DroidProvider | null {
|
||||
if (!provider) return null;
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
if (normalized === 'anthropic' || ANTHROPIC_PROVIDER_ALIASES.has(normalized)) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (normalized === 'openai' || OPENAI_PROVIDER_ALIASES.has(normalized)) {
|
||||
return 'openai';
|
||||
}
|
||||
if (normalized === 'generic-chat-completion-api' || GENERIC_PROVIDER_ALIASES.has(normalized)) {
|
||||
return 'generic-chat-completion-api';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer provider primarily from base URL patterns used in BYOK configs.
|
||||
*/
|
||||
export function inferDroidProviderFromBaseUrl(
|
||||
baseUrl: string | undefined | null
|
||||
): DroidProvider | null {
|
||||
if (!baseUrl) return null;
|
||||
const raw = baseUrl.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.host.toLowerCase();
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
|
||||
if (
|
||||
host.includes('api.openai.com') ||
|
||||
host.includes('.openai.azure.com') ||
|
||||
host.includes('.services.ai.azure.com')
|
||||
) {
|
||||
return 'openai';
|
||||
}
|
||||
|
||||
if (host.includes('anthropic.com') || pathname.includes('/anthropic')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
|
||||
if (
|
||||
host.includes('openrouter.ai') ||
|
||||
host.includes('api.groq.com') ||
|
||||
host.includes('api.deepinfra.com') ||
|
||||
host.includes('api.fireworks.ai') ||
|
||||
host.includes('inference.baseten.co') ||
|
||||
host.includes('huggingface.co') ||
|
||||
host.includes('ollama.com') ||
|
||||
pathname.includes('/openai') ||
|
||||
pathname.includes('/chat/completions')
|
||||
) {
|
||||
return 'generic-chat-completion-api';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer provider from model naming when URL does not provide a clear signal.
|
||||
*/
|
||||
export function inferDroidProviderFromModel(
|
||||
model: string | undefined | null
|
||||
): DroidProvider | null {
|
||||
if (!model) return null;
|
||||
const normalized = model.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
if (normalized.startsWith('claude-')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (
|
||||
normalized.startsWith('gpt-') ||
|
||||
normalized.startsWith('o1') ||
|
||||
normalized.startsWith('o3') ||
|
||||
normalized.startsWith('o4')
|
||||
) {
|
||||
return 'openai';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface DroidProviderResolveInput {
|
||||
provider?: string | null;
|
||||
baseUrl?: string | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider for Droid custom model entries.
|
||||
*
|
||||
* Precedence:
|
||||
* 1) explicit provider hint (CCS_DROID_PROVIDER)
|
||||
* 2) base URL inference
|
||||
* 3) model inference
|
||||
* 4) anthropic (backward-compatible default for legacy CCS profiles)
|
||||
*/
|
||||
export function resolveDroidProvider(input: DroidProviderResolveInput): DroidProvider {
|
||||
const explicit = normalizeDroidProvider(input.provider);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const fromUrl = inferDroidProviderFromBaseUrl(input.baseUrl);
|
||||
if (fromUrl) return fromUrl;
|
||||
|
||||
const fromModel = inferDroidProviderFromModel(input.model);
|
||||
if (fromModel) return fromModel;
|
||||
|
||||
return 'anthropic';
|
||||
}
|
||||
@@ -27,4 +27,6 @@ export {
|
||||
pruneOrphanedModels,
|
||||
} from './droid-config-manager';
|
||||
export type { DroidCustomModel } from './droid-config-manager';
|
||||
export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider';
|
||||
export type { DroidProvider } from './droid-provider';
|
||||
export { resolveTargetType, stripTargetFlag } from './target-resolver';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../api/services/profile-writer';
|
||||
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||
import { updateSettingsFile } from './route-helpers';
|
||||
|
||||
const router = Router();
|
||||
@@ -61,12 +62,20 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
*/
|
||||
router.post('/', (req: Request, res: Response): void => {
|
||||
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
||||
const providerHint = req.body?.droidProvider ?? req.body?.provider;
|
||||
const parsedProvider = normalizeDroidProvider(providerHint);
|
||||
|
||||
const parsedTarget = parseTarget(target);
|
||||
if (target !== undefined && parsedTarget === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
return;
|
||||
}
|
||||
if (providerHint !== undefined && parsedProvider === null) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name || !baseUrl || !apiKey) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' });
|
||||
@@ -99,7 +108,8 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
sonnet: sonnetModel || model || '',
|
||||
haiku: haikuModel || model || '',
|
||||
},
|
||||
parsedTarget || 'claude'
|
||||
parsedTarget || 'claude',
|
||||
parsedProvider || undefined
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -120,12 +130,20 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
router.put('/:name', (req: Request, res: Response): void => {
|
||||
const { name } = req.params;
|
||||
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
||||
const providerHint = req.body?.droidProvider ?? req.body?.provider;
|
||||
const parsedProvider = normalizeDroidProvider(providerHint);
|
||||
|
||||
const parsedTarget = parseTarget(target);
|
||||
if (target !== undefined && parsedTarget === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
return;
|
||||
}
|
||||
if (providerHint !== undefined && parsedProvider === null) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if profile exists (uses unified config when available)
|
||||
if (!apiProfileExists(name)) {
|
||||
@@ -150,7 +168,8 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
model !== undefined ||
|
||||
opusModel !== undefined ||
|
||||
sonnetModel !== undefined ||
|
||||
haikuModel !== undefined;
|
||||
haikuModel !== undefined ||
|
||||
providerHint !== undefined;
|
||||
const hasTargetUpdate = target !== undefined;
|
||||
|
||||
if (!hasSettingsUpdates && !hasTargetUpdate) {
|
||||
@@ -159,7 +178,15 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
if (hasSettingsUpdates) {
|
||||
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
|
||||
updateSettingsFile(name, {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
opusModel,
|
||||
sonnetModel,
|
||||
haikuModel,
|
||||
provider: parsedProvider || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasTargetUpdate && parsedTarget) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||
import type { Config, Settings } from '../../types/config';
|
||||
|
||||
/** Model mapping for API profiles */
|
||||
@@ -64,10 +65,16 @@ export function createSettingsFile(
|
||||
name: string,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
models: ModelMapping = {}
|
||||
models: ModelMapping = {},
|
||||
provider?: string
|
||||
): string {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
const { model, opusModel, sonnetModel, haikuModel } = models;
|
||||
const droidProvider = resolveDroidProvider({
|
||||
provider,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
|
||||
const settings: Settings = {
|
||||
env: {
|
||||
@@ -77,6 +84,7 @@ export function createSettingsFile(
|
||||
...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }),
|
||||
...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }),
|
||||
...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }),
|
||||
CCS_DROID_PROVIDER: droidProvider,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -96,6 +104,7 @@ export function updateSettingsFile(
|
||||
opusModel?: string;
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
provider?: string;
|
||||
}
|
||||
): void {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
@@ -153,6 +162,21 @@ export function updateSettingsFile(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
updates.provider !== undefined ||
|
||||
updates.baseUrl !== undefined ||
|
||||
updates.model !== undefined ||
|
||||
settings.env?.CCS_DROID_PROVIDER
|
||||
) {
|
||||
settings.env = settings.env || {};
|
||||
const resolvedProvider = resolveDroidProvider({
|
||||
provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER,
|
||||
baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL,
|
||||
model: updates.model ?? settings.env.ANTHROPIC_MODEL,
|
||||
});
|
||||
settings.env.CCS_DROID_PROVIDER = resolvedProvider;
|
||||
}
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,21 @@ describe('droid-config-manager', () => {
|
||||
});
|
||||
|
||||
describe('upsertCcsModel', () => {
|
||||
it('should return a selector reference for the managed model', async () => {
|
||||
const ref = await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
expect(ref.profile).toBe('gemini');
|
||||
expect(ref.selectorAlias).toBe('CCS-gemini-0');
|
||||
expect(ref.selector).toBe('custom:CCS-gemini-0');
|
||||
expect(ref.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should create settings.json with customModels', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
normalizeDroidProvider,
|
||||
inferDroidProviderFromBaseUrl,
|
||||
inferDroidProviderFromModel,
|
||||
resolveDroidProvider,
|
||||
} from '../../../src/targets/droid-provider';
|
||||
|
||||
describe('droid-provider', () => {
|
||||
describe('normalizeDroidProvider', () => {
|
||||
it('accepts canonical provider names', () => {
|
||||
expect(normalizeDroidProvider('anthropic')).toBe('anthropic');
|
||||
expect(normalizeDroidProvider('openai')).toBe('openai');
|
||||
expect(normalizeDroidProvider('generic-chat-completion-api')).toBe(
|
||||
'generic-chat-completion-api'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes compatibility aliases', () => {
|
||||
expect(normalizeDroidProvider('anthropic-compatible')).toBe('anthropic');
|
||||
expect(normalizeDroidProvider('openai-compatible')).toBe('generic-chat-completion-api');
|
||||
});
|
||||
|
||||
it('returns null for unknown values', () => {
|
||||
expect(normalizeDroidProvider('')).toBeNull();
|
||||
expect(normalizeDroidProvider('unsupported')).toBeNull();
|
||||
expect(normalizeDroidProvider(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferDroidProviderFromBaseUrl', () => {
|
||||
it('detects anthropic-compatible endpoints', () => {
|
||||
expect(inferDroidProviderFromBaseUrl('https://api.anthropic.com')).toBe('anthropic');
|
||||
expect(inferDroidProviderFromBaseUrl('https://api.z.ai/api/anthropic')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects openai official endpoints', () => {
|
||||
expect(inferDroidProviderFromBaseUrl('https://api.openai.com/v1')).toBe('openai');
|
||||
});
|
||||
|
||||
it('detects generic openai-chat-compatible endpoints', () => {
|
||||
expect(inferDroidProviderFromBaseUrl('https://openrouter.ai/api/v1')).toBe(
|
||||
'generic-chat-completion-api'
|
||||
);
|
||||
expect(inferDroidProviderFromBaseUrl('https://api.deepinfra.com/v1/openai')).toBe(
|
||||
'generic-chat-completion-api'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferDroidProviderFromModel', () => {
|
||||
it('detects anthropic model naming', () => {
|
||||
expect(inferDroidProviderFromModel('claude-sonnet-4-5-20250929')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects openai model naming', () => {
|
||||
expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDroidProvider', () => {
|
||||
it('prefers explicit provider', () => {
|
||||
expect(
|
||||
resolveDroidProvider({
|
||||
provider: 'generic-chat-completion-api',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
})
|
||||
).toBe('generic-chat-completion-api');
|
||||
});
|
||||
|
||||
it('falls back to URL inference when provider hint is missing', () => {
|
||||
expect(resolveDroidProvider({ baseUrl: 'https://api.openai.com/v1' })).toBe('openai');
|
||||
});
|
||||
|
||||
it('defaults to anthropic for legacy profiles without clear signal', () => {
|
||||
expect(resolveDroidProvider({ baseUrl: 'http://127.0.0.1:8317' })).toBe('anthropic');
|
||||
expect(resolveDroidProvider({})).toBe('anthropic');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,8 @@ describe('DroidAdapter', () => {
|
||||
});
|
||||
|
||||
it('should build args with -m custom:ccs- prefix', () => {
|
||||
const args = adapter.buildArgs('gemini', ['--verbose']);
|
||||
const isolatedAdapter = new DroidAdapter();
|
||||
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
|
||||
expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']);
|
||||
});
|
||||
|
||||
@@ -183,4 +184,27 @@ describe('DroidAdapter', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('buildArgs should use selector returned from Droid settings entry', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
|
||||
try {
|
||||
const isolatedAdapter = new DroidAdapter();
|
||||
await isolatedAdapter.prepareCredentials({
|
||||
profile: 'gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
});
|
||||
|
||||
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
|
||||
expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user