Skip to content

Commit f83051b

Browse files
committed
feat(api): improve create UX with URL validation and model mapping
- Add URL warning for common mistakes (e.g., /chat/completions endpoint) - Add optional model mapping prompt for Opus/Sonnet/Haiku backends - Show edit hint after profile creation for modifying settings - Support custom model configurations in unified config mode Closes #72
1 parent cbeb28f commit f83051b

File tree

1 file changed

+143
-35
lines changed

1 file changed

+143
-35
lines changed

src/commands/api-command.ts

Lines changed: 143 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ function validateApiName(name: string): string | null {
9393
}
9494

9595
/**
96-
* Validate URL format
96+
* Validate URL format and warn about common mistakes
9797
*/
9898
function validateUrl(url: string): string | null {
9999
if (!url) {
@@ -107,6 +107,25 @@ function validateUrl(url: string): string | null {
107107
}
108108
}
109109

110+
/**
111+
* Check if URL looks like it includes endpoint path (common mistake)
112+
* Returns warning message if problematic, null if OK
113+
*/
114+
function getUrlWarning(url: string): string | null {
115+
const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
116+
const lowerUrl = url.toLowerCase();
117+
118+
for (const path of problematicPaths) {
119+
if (lowerUrl.endsWith(path)) {
120+
return (
121+
`URL ends with "${path}" - Claude appends this automatically.\n` +
122+
` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}`
123+
);
124+
}
125+
}
126+
return null;
127+
}
128+
110129
/**
111130
* Check if unified config mode is active
112131
*/
@@ -130,22 +149,35 @@ function apiExists(name: string): boolean {
130149
}
131150
}
132151

152+
/** Model mapping for API profiles */
153+
interface ModelMapping {
154+
default: string;
155+
opus: string;
156+
sonnet: string;
157+
haiku: string;
158+
}
159+
133160
/**
134161
* Create settings.json file for API profile
135162
* Includes all 4 model fields for proper Claude CLI integration
136163
*/
137-
function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string {
164+
function createSettingsFile(
165+
name: string,
166+
baseUrl: string,
167+
apiKey: string,
168+
models: ModelMapping
169+
): string {
138170
const ccsDir = getCcsDir();
139171
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
140172

141173
const settings = {
142174
env: {
143175
ANTHROPIC_BASE_URL: baseUrl,
144176
ANTHROPIC_AUTH_TOKEN: apiKey,
145-
ANTHROPIC_MODEL: model,
146-
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
147-
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
148-
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
177+
ANTHROPIC_MODEL: models.default,
178+
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
179+
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
180+
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
149181
},
150182
};
151183

@@ -192,7 +224,7 @@ function createApiProfileUnified(
192224
name: string,
193225
baseUrl: string,
194226
apiKey: string,
195-
model: string
227+
models: ModelMapping
196228
): void {
197229
const ccsDir = path.join(os.homedir(), '.ccs');
198230
const settingsFile = `${name}.settings.json`;
@@ -203,10 +235,10 @@ function createApiProfileUnified(
203235
env: {
204236
ANTHROPIC_BASE_URL: baseUrl,
205237
ANTHROPIC_AUTH_TOKEN: apiKey,
206-
ANTHROPIC_MODEL: model,
207-
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
208-
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
209-
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
238+
ANTHROPIC_MODEL: models.default,
239+
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
240+
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
241+
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
210242
},
211243
};
212244

@@ -293,9 +325,12 @@ async function handleCreate(args: string[]): Promise<void> {
293325
// Step 2: Base URL
294326
let baseUrl = parsedArgs.baseUrl;
295327
if (!baseUrl) {
296-
baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', {
297-
validate: validateUrl,
298-
});
328+
baseUrl = await InteractivePrompt.input(
329+
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
330+
{
331+
validate: validateUrl,
332+
}
333+
);
299334
} else {
300335
const error = validateUrl(baseUrl);
301336
if (error) {
@@ -304,6 +339,23 @@ async function handleCreate(args: string[]): Promise<void> {
304339
}
305340
}
306341

342+
// Check for common URL mistakes and warn
343+
const urlWarning = getUrlWarning(baseUrl);
344+
if (urlWarning) {
345+
console.log('');
346+
console.log(warn(urlWarning));
347+
const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', {
348+
default: false,
349+
});
350+
if (!continueAnyway) {
351+
// Let user re-enter URL
352+
baseUrl = await InteractivePrompt.input('API Base URL', {
353+
validate: validateUrl,
354+
default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''),
355+
});
356+
}
357+
}
358+
307359
// Step 3: API Key
308360
let apiKey = parsedArgs.apiKey;
309361
if (!apiKey) {
@@ -324,44 +376,100 @@ async function handleCreate(args: string[]): Promise<void> {
324376
}
325377
model = model || defaultModel;
326378

379+
// Step 5: Optional model mapping for Opus/Sonnet/Haiku
380+
// Ask user if they want different models for each type
381+
let opusModel = model;
382+
let sonnetModel = model;
383+
let haikuModel = model;
384+
385+
if (!parsedArgs.yes) {
386+
console.log('');
387+
console.log(dim('Some API proxies route different model types to different backends.'));
388+
const wantCustomMapping = await InteractivePrompt.confirm(
389+
'Configure different models for Opus/Sonnet/Haiku?',
390+
{ default: false }
391+
);
392+
393+
if (wantCustomMapping) {
394+
console.log('');
395+
console.log(dim('Leave blank to use the default model for each.'));
396+
opusModel = (await InteractivePrompt.input('Opus model', { default: model })) || model;
397+
sonnetModel = (await InteractivePrompt.input('Sonnet model', { default: model })) || model;
398+
haikuModel = (await InteractivePrompt.input('Haiku model', { default: model })) || model;
399+
}
400+
}
401+
402+
// Build model mapping
403+
const models: ModelMapping = {
404+
default: model,
405+
opus: opusModel,
406+
sonnet: sonnetModel,
407+
haiku: haikuModel,
408+
};
409+
410+
// Check if custom model mapping is configured
411+
const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model;
412+
327413
// Create files
328414
console.log('');
329415
console.log(info('Creating API profile...'));
330416

331417
try {
418+
const settingsFile = `~/.ccs/${name}.settings.json`;
419+
332420
if (isUnifiedMode()) {
333421
// Use unified config format
334-
createApiProfileUnified(name, baseUrl, apiKey, model);
422+
createApiProfileUnified(name, baseUrl, apiKey, models);
335423
console.log('');
336-
console.log(
337-
infoBox(
338-
`API: ${name}\n` +
339-
`Config: ~/.ccs/config.yaml\n` +
340-
`Secrets: ~/.ccs/secrets.yaml\n` +
341-
`Base URL: ${baseUrl}\n` +
342-
`Model: ${model}`,
343-
'API Profile Created (Unified Config)'
344-
)
345-
);
424+
425+
// Build info message
426+
let infoMsg =
427+
`API: ${name}\n` +
428+
`Config: ~/.ccs/config.yaml\n` +
429+
`Settings: ${settingsFile}\n` +
430+
`Base URL: ${baseUrl}\n` +
431+
`Model: ${model}`;
432+
433+
if (hasCustomMapping) {
434+
infoMsg +=
435+
`\n\nModel Mapping:\n` +
436+
` Opus: ${opusModel}\n` +
437+
` Sonnet: ${sonnetModel}\n` +
438+
` Haiku: ${haikuModel}`;
439+
}
440+
441+
console.log(infoBox(infoMsg, 'API Profile Created'));
346442
} else {
347443
// Use legacy JSON format
348-
const settingsPath = createSettingsFile(name, baseUrl, apiKey, model);
444+
const settingsPath = createSettingsFile(name, baseUrl, apiKey, models);
349445
updateConfig(name, settingsPath);
350446
console.log('');
351-
console.log(
352-
infoBox(
353-
`API: ${name}\n` +
354-
`Settings: ~/.ccs/${name}.settings.json\n` +
355-
`Base URL: ${baseUrl}\n` +
356-
`Model: ${model}`,
357-
'API Profile Created'
358-
)
359-
);
447+
448+
let infoMsg =
449+
`API: ${name}\n` +
450+
`Settings: ${settingsFile}\n` +
451+
`Base URL: ${baseUrl}\n` +
452+
`Model: ${model}`;
453+
454+
if (hasCustomMapping) {
455+
infoMsg +=
456+
`\n\nModel Mapping:\n` +
457+
` Opus: ${opusModel}\n` +
458+
` Sonnet: ${sonnetModel}\n` +
459+
` Haiku: ${haikuModel}`;
460+
}
461+
462+
console.log(infoBox(infoMsg, 'API Profile Created'));
360463
}
464+
361465
console.log('');
362466
console.log(header('Usage'));
363467
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
364468
console.log('');
469+
console.log(header('Edit Settings'));
470+
console.log(` ${dim('To modify env vars later:')}`);
471+
console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`);
472+
console.log('');
365473
} catch (error) {
366474
console.log(fail(`Failed to create API profile: ${(error as Error).message}`));
367475
process.exit(1);

0 commit comments

Comments
 (0)