mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(droid): avoid interactive model arg prompt pollution
- persist selector in ~/.factory/settings.json as settings.model - stop injecting '-m custom:...' for interactive droid launches - improve provider inference for localhost /v1 and qwen/deepseek/kimi - add regression tests for adapter, provider, and config manager
This commit is contained in:
@@ -162,6 +162,10 @@ For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hin
|
||||
`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:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Droid Adapter
|
||||
*
|
||||
* TargetAdapter implementation for Factory Droid CLI.
|
||||
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:<alias>`.
|
||||
* Writes credentials + active model to ~/.factory/settings.json and spawns `droid`.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
@@ -19,7 +19,6 @@ 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()) {
|
||||
@@ -57,7 +56,9 @@ export class DroidAdapter implements TargetAdapter {
|
||||
apiKey: creds.apiKey,
|
||||
provider,
|
||||
});
|
||||
this.modelSelectorsByProfile.set(creds.profile, modelRef.selector);
|
||||
if (!modelRef.selector) {
|
||||
throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`);
|
||||
}
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
||||
@@ -66,8 +67,9 @@ export class DroidAdapter implements TargetAdapter {
|
||||
`Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed`
|
||||
);
|
||||
}
|
||||
const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`;
|
||||
return ['-m', selector, ...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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface DroidManagedModelRef {
|
||||
}
|
||||
|
||||
interface DroidSettings {
|
||||
model?: string;
|
||||
customModels?: DroidCustomModelEntry[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -346,19 +347,21 @@ export async function upsertCcsModel(
|
||||
settings.customModels.push(entry);
|
||||
}
|
||||
|
||||
writeDroidSettings(settings);
|
||||
|
||||
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: `custom:${selectorAlias}`,
|
||||
selector,
|
||||
};
|
||||
} finally {
|
||||
if (release) await release();
|
||||
|
||||
@@ -65,6 +65,8 @@ export function inferDroidProviderFromBaseUrl(
|
||||
|
||||
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') ||
|
||||
@@ -92,6 +94,11 @@ export function inferDroidProviderFromBaseUrl(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -116,6 +123,13 @@ export function inferDroidProviderFromModel(
|
||||
) {
|
||||
return 'openai';
|
||||
}
|
||||
if (
|
||||
normalized.startsWith('qwen') ||
|
||||
normalized.startsWith('deepseek') ||
|
||||
normalized.startsWith('kimi')
|
||||
) {
|
||||
return 'generic-chat-completion-api';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -45,6 +45,10 @@ describe('droid-config-manager', () => {
|
||||
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 () => {
|
||||
@@ -63,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 () => {
|
||||
|
||||
@@ -46,6 +46,18 @@ describe('droid-provider', () => {
|
||||
'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', () => {
|
||||
@@ -56,6 +68,12 @@ describe('droid-provider', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -125,10 +125,16 @@ describe('DroidAdapter', () => {
|
||||
expect(adapter.supportsProfileType('copilot')).toBe(false);
|
||||
});
|
||||
|
||||
it('should build args with -m custom:ccs- prefix', () => {
|
||||
it('should keep interactive args clean (no model argv injection)', () => {
|
||||
const isolatedAdapter = new DroidAdapter();
|
||||
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
|
||||
expect(args).toEqual(['-m', 'custom:ccs-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)', () => {
|
||||
@@ -200,7 +206,7 @@ describe('DroidAdapter', () => {
|
||||
});
|
||||
|
||||
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
|
||||
expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']);
|
||||
expect(args).toEqual(['--verbose']);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
Reference in New Issue
Block a user