mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +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:
1 parent
60b1c043c7
commit
f1567c0090
8 files changed
+65
-13
No files matched your search
@@ -162,6 +162,10 @@ For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hin
|
|||||||
`anthropic`, `openai`, or `generic-chat-completion-api`.
|
`anthropic`, `openai`, or `generic-chat-completion-api`.
|
||||||
If the hint is missing, CCS resolves provider from base URL/model at runtime.
|
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
|
### 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:
|
||||||
|
|||||||
@@ -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:<alias>`.
|
* Writes credentials + active model to ~/.factory/settings.json and spawns `droid`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
@@ -19,7 +19,6 @@ 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()) {
|
||||||
@@ -57,7 +56,9 @@ export class DroidAdapter implements TargetAdapter {
|
|||||||
apiKey: creds.apiKey,
|
apiKey: creds.apiKey,
|
||||||
provider,
|
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[] {
|
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`
|
`Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`;
|
// Droid interactive mode treats unknown argv as queued prompt text.
|
||||||
return ['-m', selector, ...userArgs];
|
// Model selection must be persisted in settings.json (`model`) instead of `-m`.
|
||||||
|
return [...userArgs];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface DroidManagedModelRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DroidSettings {
|
interface DroidSettings {
|
||||||
|
model?: string;
|
||||||
customModels?: DroidCustomModelEntry[];
|
customModels?: DroidCustomModelEntry[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -346,19 +347,21 @@ export async function upsertCcsModel(
|
|||||||
settings.customModels.push(entry);
|
settings.customModels.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeDroidSettings(settings);
|
|
||||||
|
|
||||||
const index = settings.customModels.findIndex(
|
const index = settings.customModels.findIndex(
|
||||||
(entry) => parseManagedProfile(entry.displayName) === profile
|
(entry) => parseManagedProfile(entry.displayName) === profile
|
||||||
);
|
);
|
||||||
const safeIndex = index >= 0 ? index : 0;
|
const safeIndex = index >= 0 ? index : 0;
|
||||||
const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex);
|
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 = {
|
ref = {
|
||||||
profile,
|
profile,
|
||||||
displayName: entry.displayName,
|
displayName: entry.displayName,
|
||||||
index: safeIndex,
|
index: safeIndex,
|
||||||
selectorAlias,
|
selectorAlias,
|
||||||
selector: `custom:${selectorAlias}`,
|
selector,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (release) await release();
|
if (release) await release();
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export function inferDroidProviderFromBaseUrl(
|
|||||||
|
|
||||||
const host = parsed.host.toLowerCase();
|
const host = parsed.host.toLowerCase();
|
||||||
const pathname = parsed.pathname.toLowerCase();
|
const pathname = parsed.pathname.toLowerCase();
|
||||||
|
const isLocalHost =
|
||||||
|
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]');
|
||||||
|
|
||||||
if (
|
if (
|
||||||
host.includes('api.openai.com') ||
|
host.includes('api.openai.com') ||
|
||||||
@@ -92,6 +94,11 @@ export function inferDroidProviderFromBaseUrl(
|
|||||||
return 'generic-chat-completion-api';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +123,13 @@ export function inferDroidProviderFromModel(
|
|||||||
) {
|
) {
|
||||||
return 'openai';
|
return 'openai';
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
normalized.startsWith('qwen') ||
|
||||||
|
normalized.startsWith('deepseek') ||
|
||||||
|
normalized.startsWith('kimi')
|
||||||
|
) {
|
||||||
|
return 'generic-chat-completion-api';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { describe, it, expect } from 'bun:test';
|
|||||||
import { DroidAdapter } from '../../../src/targets/droid-adapter';
|
import { DroidAdapter } from '../../../src/targets/droid-adapter';
|
||||||
|
|
||||||
describe('DroidAdapter.buildArgs', () => {
|
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();
|
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', () => {
|
it('rejects unsafe profile names', () => {
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ describe('droid-config-manager', () => {
|
|||||||
expect(ref.selectorAlias).toBe('CCS-gemini-0');
|
expect(ref.selectorAlias).toBe('CCS-gemini-0');
|
||||||
expect(ref.selector).toBe('custom:CCS-gemini-0');
|
expect(ref.selector).toBe('custom:CCS-gemini-0');
|
||||||
expect(ref.index).toBe(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 () => {
|
it('should create settings.json with customModels', async () => {
|
||||||
@@ -63,6 +67,7 @@ describe('droid-config-manager', () => {
|
|||||||
expect(settings.customModels).toHaveLength(1);
|
expect(settings.customModels).toHaveLength(1);
|
||||||
expect(settings.customModels[0].displayName).toBe('CCS gemini');
|
expect(settings.customModels[0].displayName).toBe('CCS gemini');
|
||||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317');
|
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 () => {
|
it('should update existing entry on second upsert', async () => {
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ describe('droid-provider', () => {
|
|||||||
'generic-chat-completion-api'
|
'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', () => {
|
describe('inferDroidProviderFromModel', () => {
|
||||||
@@ -56,6 +68,12 @@ describe('droid-provider', () => {
|
|||||||
it('detects openai model naming', () => {
|
it('detects openai model naming', () => {
|
||||||
expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai');
|
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', () => {
|
describe('resolveDroidProvider', () => {
|
||||||
|
|||||||
@@ -125,10 +125,16 @@ describe('DroidAdapter', () => {
|
|||||||
expect(adapter.supportsProfileType('copilot')).toBe(false);
|
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 isolatedAdapter = new DroidAdapter();
|
||||||
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
|
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)', () => {
|
it('should build minimal env (no ANTHROPIC_ vars)', () => {
|
||||||
@@ -200,7 +206,7 @@ describe('DroidAdapter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
|
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
|
||||||
expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']);
|
expect(args).toEqual(['--verbose']);
|
||||||
} finally {
|
} finally {
|
||||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||||
else delete process.env.CCS_HOME;
|
else delete process.env.CCS_HOME;
|
||||||
|
|||||||
Reference in new issue
Block a user