Merge pull request #633 from kaitranntt/kai/feat/632-droid-byok-provider

feat(droid): align BYOK provider mapping with Factory schema
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-25 10:19:16 -05:00
commit 84d618298e
13 files changed
+477 -20

No files matched your search

+9
View File
@@ -157,6 +157,15 @@ 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.
CCS also persists Droid's active model selector in `~/.factory/settings.json`
(`model: custom:<alias>`). This avoids passing `-m` argv in interactive mode,
which Droid treats as queued prompt text.
### Per-Profile Target Defaults
You can pin a default target (`claude` or `droid`) per profile:
+21 -5
View File
@@ -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
View File
@@ -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(
+15 -4
View File
@@ -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 + active model to ~/.factory/settings.json and spawns `droid`.
*/
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';
@@ -43,13 +44,21 @@ 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,
});
if (!modelRef.selector) {
throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`);
}
}
buildArgs(profile: string, userArgs: string[]): string[] {
@@ -58,7 +67,9 @@ export class DroidAdapter implements TargetAdapter {
`Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed`
);
}
return ['-m', `custom:ccs-${profile}`, ...userArgs];
// Droid interactive mode treats unknown argv as queued prompt text.
// Model selection must be persisted in settings.json (`model`) instead of `-m`.
return [...userArgs];
}
/**
+44 -1
View File
@@ -43,7 +43,16 @@ export interface DroidCustomModel {
maxOutputTokens?: number;
}
export interface DroidManagedModelRef {
profile: string;
displayName: string;
index: number;
selectorAlias: string;
selector: string;
}
interface DroidSettings {
model?: string;
customModels?: DroidCustomModelEntry[];
[key: string]: unknown;
}
@@ -97,6 +106,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 +316,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);
@@ -329,10 +347,35 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel):
settings.customModels.push(entry);
}
const index = settings.customModels.findIndex(
(entry) => parseManagedProfile(entry.displayName) === profile
);
const safeIndex = index >= 0 ? index : 0;
const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex);
const selector = `custom:${selectorAlias}`;
// Droid interactive mode uses settings.model for default model selection.
settings.model = selector;
writeDroidSettings(settings);
ref = {
profile,
displayName: entry.displayName,
index: safeIndex,
selectorAlias,
selector,
};
} finally {
if (release) await release();
}
return (
ref || {
profile,
displayName: `CCS ${profile}`,
index: 0,
selectorAlias: `CCS-${profile}-0`,
selector: `custom:CCS-${profile}-0`,
}
);
}
/**
+162
View File
@@ -0,0 +1,162 @@
/**
* 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();
const isLocalHost =
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]');
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';
}
// Local OpenAI-compatible proxies are commonly exposed at /v1.
if (isLocalHost && (pathname === '/v1' || pathname.startsWith('/v1/'))) {
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';
}
if (
normalized.startsWith('qwen') ||
normalized.startsWith('deepseek') ||
normalized.startsWith('kimi')
) {
return 'generic-chat-completion-api';
}
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';
}
+2
View File
@@ -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';
+30 -3
View File
@@ -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) {
+25 -1
View File
@@ -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');
}
+2 -2
View File
@@ -5,9 +5,9 @@ import { describe, it, expect } from 'bun:test';
import { DroidAdapter } from '../../../src/targets/droid-adapter';
describe('DroidAdapter.buildArgs', () => {
it('builds droid model args for valid profile names', () => {
it('passes user args without model injection for valid profile names', () => {
const adapter = new DroidAdapter();
expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['-m', 'custom:ccs-gemini_01', '--help']);
expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['--help']);
});
it('rejects unsafe profile names', () => {
@@ -32,6 +32,25 @@ 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);
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should create settings.json with customModels', async () => {
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
@@ -48,6 +67,7 @@ describe('droid-config-manager', () => {
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('CCS gemini');
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317');
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should update existing entry on second upsert', async () => {
+98
View File
@@ -0,0 +1,98 @@
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'
);
});
it('detects localhost openai-compatible /v1 endpoints', () => {
expect(inferDroidProviderFromBaseUrl('http://127.0.0.1:1234/v1')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('http://localhost:8317/v1/chat/completions')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('http://[::1]:8317/v1')).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');
});
it('detects generic openai-compatible model families', () => {
expect(inferDroidProviderFromModel('qwen3-coder-plus')).toBe('generic-chat-completion-api');
expect(inferDroidProviderFromModel('deepseek-v3.1')).toBe('generic-chat-completion-api');
expect(inferDroidProviderFromModel('kimi-k2')).toBe('generic-chat-completion-api');
});
});
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');
});
});
});
+33 -3
View File
@@ -125,9 +125,16 @@ describe('DroidAdapter', () => {
expect(adapter.supportsProfileType('copilot')).toBe(false);
});
it('should build args with -m custom:ccs- prefix', () => {
const args = adapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']);
it('should keep interactive args clean (no model argv injection)', () => {
const isolatedAdapter = new DroidAdapter();
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['--verbose']);
});
it('should not queue model selector as prompt when no user args', () => {
const isolatedAdapter = new DroidAdapter();
const args = isolatedAdapter.buildArgs('codex', []);
expect(args).toEqual([]);
});
it('should build minimal env (no ANTHROPIC_ vars)', () => {
@@ -183,4 +190,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(['--verbose']);
} finally {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});