mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +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:
1 parent
e67ca539b2
commit
60b1c043c7
12 files changed
+421
-16
No files matched your search
@@ -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`).
|
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
|
### Per-Profile Target Defaults
|
||||||
|
|
||||||
You can pin a default target (`claude` or `droid`) per profile:
|
You can pin a default target (`claude` or `droid`) per profile:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../../config/unified-config-loader';
|
} from '../../config/unified-config-loader';
|
||||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
|
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||||
import type {
|
import type {
|
||||||
ModelMapping,
|
ModelMapping,
|
||||||
CreateApiProfileResult,
|
CreateApiProfileResult,
|
||||||
@@ -30,10 +31,16 @@ function createSettingsFile(
|
|||||||
name: string,
|
name: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
models: ModelMapping
|
models: ModelMapping,
|
||||||
|
provider?: string
|
||||||
): string {
|
): string {
|
||||||
const ccsDir = getCcsDir();
|
const ccsDir = getCcsDir();
|
||||||
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
|
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
|
||||||
|
const droidProvider = resolveDroidProvider({
|
||||||
|
provider,
|
||||||
|
baseUrl,
|
||||||
|
model: models.default,
|
||||||
|
});
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
env: {
|
env: {
|
||||||
@@ -43,6 +50,7 @@ function createSettingsFile(
|
|||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||||
|
CCS_DROID_PROVIDER: droidProvider,
|
||||||
// OpenRouter requires explicitly blanking the API key to prevent conflicts
|
// OpenRouter requires explicitly blanking the API key to prevent conflicts
|
||||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||||
},
|
},
|
||||||
@@ -97,11 +105,17 @@ function createApiProfileUnified(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
models: ModelMapping,
|
models: ModelMapping,
|
||||||
target: TargetType = 'claude'
|
target: TargetType = 'claude',
|
||||||
|
provider?: string
|
||||||
): void {
|
): void {
|
||||||
const ccsDir = getCcsDir();
|
const ccsDir = getCcsDir();
|
||||||
const settingsFile = `${name}.settings.json`;
|
const settingsFile = `${name}.settings.json`;
|
||||||
const settingsPath = path.join(ccsDir, settingsFile);
|
const settingsPath = path.join(ccsDir, settingsFile);
|
||||||
|
const droidProvider = resolveDroidProvider({
|
||||||
|
provider,
|
||||||
|
baseUrl,
|
||||||
|
model: models.default,
|
||||||
|
});
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
env: {
|
env: {
|
||||||
@@ -111,6 +125,7 @@ function createApiProfileUnified(
|
|||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||||
|
CCS_DROID_PROVIDER: droidProvider,
|
||||||
// OpenRouter requires explicitly blanking the API key to prevent conflicts
|
// OpenRouter requires explicitly blanking the API key to prevent conflicts
|
||||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||||
},
|
},
|
||||||
@@ -140,15 +155,16 @@ export function createApiProfile(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
models: ModelMapping,
|
models: ModelMapping,
|
||||||
target: TargetType = 'claude'
|
target: TargetType = 'claude',
|
||||||
|
provider?: string
|
||||||
): CreateApiProfileResult {
|
): CreateApiProfileResult {
|
||||||
try {
|
try {
|
||||||
const settingsFile = `~/.ccs/${name}.settings.json`;
|
const settingsFile = `~/.ccs/${name}.settings.json`;
|
||||||
|
|
||||||
if (isUnifiedMode()) {
|
if (isUnifiedMode()) {
|
||||||
createApiProfileUnified(name, baseUrl, apiKey, models, target);
|
createApiProfileUnified(name, baseUrl, apiKey, models, target, provider);
|
||||||
} else {
|
} else {
|
||||||
createSettingsFile(name, baseUrl, apiKey, models);
|
createSettingsFile(name, baseUrl, apiKey, models, provider);
|
||||||
updateLegacyConfig(name, target);
|
updateLegacyConfig(name, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -54,6 +54,7 @@ import {
|
|||||||
ClaudeAdapter,
|
ClaudeAdapter,
|
||||||
DroidAdapter,
|
DroidAdapter,
|
||||||
pruneOrphanedModels,
|
pruneOrphanedModels,
|
||||||
|
resolveDroidProvider,
|
||||||
type TargetCredentials,
|
type TargetCredentials,
|
||||||
} from './targets';
|
} from './targets';
|
||||||
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
|
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
|
||||||
@@ -841,7 +842,11 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: envVars['ANTHROPIC_BASE_URL'] || '',
|
baseUrl: envVars['ANTHROPIC_BASE_URL'] || '',
|
||||||
apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '',
|
apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||||
model: envVars['ANTHROPIC_MODEL'] || undefined,
|
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,
|
envVars,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1010,6 +1015,11 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
|
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
|
||||||
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '',
|
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
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);
|
await adapter.prepareCredentials(creds);
|
||||||
const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs);
|
const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs);
|
||||||
@@ -1076,6 +1086,11 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
|
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
|
||||||
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
|
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||||
model: process.env['ANTHROPIC_MODEL'],
|
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) {
|
if (!creds.baseUrl || !creds.apiKey) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Droid Adapter
|
* Droid Adapter
|
||||||
*
|
*
|
||||||
* TargetAdapter implementation for Factory Droid CLI.
|
* 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';
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
@@ -11,6 +11,7 @@ import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from '
|
|||||||
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
|
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
|
||||||
import type { ProfileType } from '../types/profile';
|
import type { ProfileType } from '../types/profile';
|
||||||
import { upsertCcsModel } from './droid-config-manager';
|
import { upsertCcsModel } from './droid-config-manager';
|
||||||
|
import { resolveDroidProvider } from './droid-provider';
|
||||||
import { escapeShellArg } from '../utils/shell-executor';
|
import { escapeShellArg } from '../utils/shell-executor';
|
||||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||||
import { runCleanup } from '../errors';
|
import { runCleanup } from '../errors';
|
||||||
@@ -18,6 +19,7 @@ import { runCleanup } from '../errors';
|
|||||||
export class DroidAdapter implements TargetAdapter {
|
export class DroidAdapter implements TargetAdapter {
|
||||||
readonly type: TargetType = 'droid';
|
readonly type: TargetType = 'droid';
|
||||||
readonly displayName = 'Factory Droid';
|
readonly displayName = 'Factory Droid';
|
||||||
|
private readonly modelSelectorsByProfile = new Map<string, string>();
|
||||||
|
|
||||||
private validateCredentials(creds: TargetCredentials): void {
|
private validateCredentials(creds: TargetCredentials): void {
|
||||||
if (!creds.baseUrl?.trim()) {
|
if (!creds.baseUrl?.trim()) {
|
||||||
@@ -43,13 +45,19 @@ export class DroidAdapter implements TargetAdapter {
|
|||||||
*/
|
*/
|
||||||
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
||||||
this.validateCredentials(creds);
|
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',
|
model: creds.model || 'claude-opus-4-6',
|
||||||
displayName: `CCS ${creds.profile}`,
|
displayName: `CCS ${creds.profile}`,
|
||||||
baseUrl: creds.baseUrl,
|
baseUrl: creds.baseUrl,
|
||||||
apiKey: creds.apiKey,
|
apiKey: creds.apiKey,
|
||||||
provider: creds.provider || 'anthropic',
|
provider,
|
||||||
});
|
});
|
||||||
|
this.modelSelectorsByProfile.set(creds.profile, modelRef.selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
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`
|
`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;
|
maxOutputTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DroidManagedModelRef {
|
||||||
|
profile: string;
|
||||||
|
displayName: string;
|
||||||
|
index: number;
|
||||||
|
selectorAlias: string;
|
||||||
|
selector: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DroidSettings {
|
interface DroidSettings {
|
||||||
customModels?: DroidCustomModelEntry[];
|
customModels?: DroidCustomModelEntry[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -97,6 +105,11 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null {
|
|||||||
return isDroidCustomModelEntry(value) ? value : 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[] {
|
function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -302,11 +315,15 @@ function writeDroidSettings(settings: DroidSettings): void {
|
|||||||
* Upsert a CCS-managed custom model entry.
|
* Upsert a CCS-managed custom model entry.
|
||||||
* Acquires file lock to prevent concurrent write races.
|
* 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);
|
validateProfileName(profile);
|
||||||
ensureFactoryDir();
|
ensureFactoryDir();
|
||||||
|
|
||||||
let release: (() => Promise<void>) | undefined;
|
let release: (() => Promise<void>) | undefined;
|
||||||
|
let ref: DroidManagedModelRef | null = null;
|
||||||
try {
|
try {
|
||||||
release = await acquireFactoryLock(10);
|
release = await acquireFactoryLock(10);
|
||||||
|
|
||||||
@@ -330,9 +347,32 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeDroidSettings(settings);
|
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 {
|
} finally {
|
||||||
if (release) await release();
|
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,
|
pruneOrphanedModels,
|
||||||
} from './droid-config-manager';
|
} from './droid-config-manager';
|
||||||
export type { DroidCustomModel } 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';
|
export { resolveTargetType, stripTargetFlag } from './target-resolver';
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../api/services/profile-writer';
|
} from '../../api/services/profile-writer';
|
||||||
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
|
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
|
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||||
import { updateSettingsFile } from './route-helpers';
|
import { updateSettingsFile } from './route-helpers';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -61,12 +62,20 @@ router.get('/', (_req: Request, res: Response): void => {
|
|||||||
*/
|
*/
|
||||||
router.post('/', (req: Request, res: Response): void => {
|
router.post('/', (req: Request, res: Response): void => {
|
||||||
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
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);
|
const parsedTarget = parseTarget(target);
|
||||||
if (target !== undefined && parsedTarget === null) {
|
if (target !== undefined && parsedTarget === null) {
|
||||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||||
return;
|
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) {
|
if (!name || !baseUrl || !apiKey) {
|
||||||
res.status(400).json({ error: 'Missing required fields: 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 || '',
|
sonnet: sonnetModel || model || '',
|
||||||
haiku: haikuModel || model || '',
|
haiku: haikuModel || model || '',
|
||||||
},
|
},
|
||||||
parsedTarget || 'claude'
|
parsedTarget || 'claude',
|
||||||
|
parsedProvider || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -120,12 +130,20 @@ router.post('/', (req: Request, res: Response): void => {
|
|||||||
router.put('/:name', (req: Request, res: Response): void => {
|
router.put('/:name', (req: Request, res: Response): void => {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
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);
|
const parsedTarget = parseTarget(target);
|
||||||
if (target !== undefined && parsedTarget === null) {
|
if (target !== undefined && parsedTarget === null) {
|
||||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||||
return;
|
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)
|
// Check if profile exists (uses unified config when available)
|
||||||
if (!apiProfileExists(name)) {
|
if (!apiProfileExists(name)) {
|
||||||
@@ -150,7 +168,8 @@ router.put('/:name', (req: Request, res: Response): void => {
|
|||||||
model !== undefined ||
|
model !== undefined ||
|
||||||
opusModel !== undefined ||
|
opusModel !== undefined ||
|
||||||
sonnetModel !== undefined ||
|
sonnetModel !== undefined ||
|
||||||
haikuModel !== undefined;
|
haikuModel !== undefined ||
|
||||||
|
providerHint !== undefined;
|
||||||
const hasTargetUpdate = target !== undefined;
|
const hasTargetUpdate = target !== undefined;
|
||||||
|
|
||||||
if (!hasSettingsUpdates && !hasTargetUpdate) {
|
if (!hasSettingsUpdates && !hasTargetUpdate) {
|
||||||
@@ -159,7 +178,15 @@ router.put('/:name', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasSettingsUpdates) {
|
if (hasSettingsUpdates) {
|
||||||
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
|
updateSettingsFile(name, {
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
opusModel,
|
||||||
|
sonnetModel,
|
||||||
|
haikuModel,
|
||||||
|
provider: parsedProvider || undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasTargetUpdate && parsedTarget) {
|
if (hasTargetUpdate && parsedTarget) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as path from 'path';
|
|||||||
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
|
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
|
||||||
import { expandPath } from '../../utils/helpers';
|
import { expandPath } from '../../utils/helpers';
|
||||||
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
||||||
|
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||||
import type { Config, Settings } from '../../types/config';
|
import type { Config, Settings } from '../../types/config';
|
||||||
|
|
||||||
/** Model mapping for API profiles */
|
/** Model mapping for API profiles */
|
||||||
@@ -64,10 +65,16 @@ export function createSettingsFile(
|
|||||||
name: string,
|
name: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
models: ModelMapping = {}
|
models: ModelMapping = {},
|
||||||
|
provider?: string
|
||||||
): string {
|
): string {
|
||||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||||
const { model, opusModel, sonnetModel, haikuModel } = models;
|
const { model, opusModel, sonnetModel, haikuModel } = models;
|
||||||
|
const droidProvider = resolveDroidProvider({
|
||||||
|
provider,
|
||||||
|
baseUrl,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
|
||||||
const settings: Settings = {
|
const settings: Settings = {
|
||||||
env: {
|
env: {
|
||||||
@@ -77,6 +84,7 @@ export function createSettingsFile(
|
|||||||
...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }),
|
...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }),
|
||||||
...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }),
|
...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }),
|
||||||
...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }),
|
...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }),
|
||||||
|
CCS_DROID_PROVIDER: droidProvider,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,6 +104,7 @@ export function updateSettingsFile(
|
|||||||
opusModel?: string;
|
opusModel?: string;
|
||||||
sonnetModel?: string;
|
sonnetModel?: string;
|
||||||
haikuModel?: string;
|
haikuModel?: string;
|
||||||
|
provider?: string;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
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');
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ describe('droid-config-manager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('upsertCcsModel', () => {
|
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 () => {
|
it('should create settings.json with customModels', async () => {
|
||||||
await upsertCcsModel('gemini', {
|
await upsertCcsModel('gemini', {
|
||||||
model: 'claude-opus-4-6',
|
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', () => {
|
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']);
|
expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,4 +184,27 @@ describe('DroidAdapter', () => {
|
|||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
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