mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 06:16:03 +00:00
Merge pull request #689 from BbChip0103/feat/anthropic-direct-api
feat(api): add Anthropic direct API key support
This commit is contained in:
19 files changed
+571
-53
No files matched your search
@@ -467,6 +467,34 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
|
||||
└─ Droid: config file (~/.factory/settings.json)
|
||||
```
|
||||
|
||||
### Anthropic Direct API Key
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Anthropic Direct API Key (Native Auth) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User creates profile: ccs api create --preset anthropic
|
||||
|
|
||||
v
|
||||
2. Key stored in ~/.ccs/<profile>.settings.json
|
||||
| env: { ANTHROPIC_API_KEY: "sk-ant-..." }
|
||||
| (NO ANTHROPIC_BASE_URL, NO ANTHROPIC_AUTH_TOKEN)
|
||||
v
|
||||
3. Profile detection: settings-based
|
||||
|
|
||||
v
|
||||
4. Key passed via ANTHROPIC_API_KEY env var
|
||||
| Claude CLI uses native endpoint (api.anthropic.com)
|
||||
v
|
||||
5. Claude CLI authenticates with x-api-key header
|
||||
|
||||
Detection logic (profile-writer.ts):
|
||||
- apiKey.startsWith('sk-ant-') -> native mode
|
||||
- baseUrl.includes('api.anthropic.com') -> native mode
|
||||
- Otherwise -> proxy mode (existing behavior)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Image Analysis Hook Flow (v7.34)
|
||||
|
||||
@@ -49,7 +49,7 @@ export function isApiProfileConfigured(apiName: string): boolean {
|
||||
if (!fs.existsSync(settingsPath)) return false;
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || '';
|
||||
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || '';
|
||||
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -32,6 +32,11 @@ function isOpenRouterUrl(baseUrl: string): boolean {
|
||||
return baseUrl.toLowerCase().includes('openrouter.ai');
|
||||
}
|
||||
|
||||
/** Detect Anthropic direct API profile (native auth, no proxy) */
|
||||
function isAnthropicDirect(baseUrl: string, apiKey: string): boolean {
|
||||
return apiKey.startsWith('sk-ant-') || baseUrl.includes('api.anthropic.com');
|
||||
}
|
||||
|
||||
function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null {
|
||||
if (baseUrl.trim().length === 0) {
|
||||
return null;
|
||||
@@ -76,20 +81,27 @@ function createSettingsFile(
|
||||
model: models.default,
|
||||
});
|
||||
|
||||
const isNative = isAnthropicDirect(baseUrl, apiKey);
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
// Native mode: ANTHROPIC_API_KEY only, no BASE_URL/AUTH_TOKEN
|
||||
// Proxy mode: ANTHROPIC_BASE_URL + AUTH_TOKEN (existing behavior)
|
||||
...(isNative
|
||||
? { ANTHROPIC_API_KEY: apiKey }
|
||||
: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
}),
|
||||
ANTHROPIC_MODEL: models.default,
|
||||
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: '' }),
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
|
||||
// Inject WebSearch hooks into profile settings
|
||||
@@ -151,17 +163,21 @@ function createApiProfileUnified(
|
||||
model: models.default,
|
||||
});
|
||||
|
||||
const isNative = isAnthropicDirect(baseUrl, apiKey);
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isNative
|
||||
? { ANTHROPIC_API_KEY: apiKey }
|
||||
: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
}),
|
||||
ANTHROPIC_MODEL: models.default,
|
||||
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: '' }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+35
-4
@@ -10,7 +10,11 @@ import {
|
||||
detectCloudSyncPath,
|
||||
} from './utils/config-manager';
|
||||
import { expandPath } from './utils/helpers';
|
||||
import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator';
|
||||
import {
|
||||
validateGlmKey,
|
||||
validateMiniMaxKey,
|
||||
validateAnthropicKey,
|
||||
} from './utils/api-key-validator';
|
||||
import { ErrorManager } from './utils/error-manager';
|
||||
import {
|
||||
execClaudeWithCLIProxy,
|
||||
@@ -1044,6 +1048,30 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL)
|
||||
{
|
||||
const preflightSettingsPath = getSettingsPath(profileInfo.name);
|
||||
const preflightSettings = loadSettings(preflightSettingsPath);
|
||||
const anthropicApiKey = preflightSettings.env?.['ANTHROPIC_API_KEY'];
|
||||
const hasBaseUrl = !!preflightSettings.env?.['ANTHROPIC_BASE_URL'];
|
||||
if (anthropicApiKey && !hasBaseUrl) {
|
||||
const validation = await validateAnthropicKey(anthropicApiKey);
|
||||
if (!validation.valid) {
|
||||
console.error('');
|
||||
console.error(fail(validation.error || 'API key validation failed'));
|
||||
if (validation.suggestion) {
|
||||
console.error('');
|
||||
console.error(validation.suggestion);
|
||||
}
|
||||
console.error('');
|
||||
console.error(
|
||||
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is GLMT profile (requires proxy)
|
||||
if (profileInfo.name === 'glmt') {
|
||||
if (resolvedTarget !== 'claude') {
|
||||
@@ -1103,14 +1131,17 @@ async function main(): Promise<void> {
|
||||
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
||||
process.exit(1);
|
||||
}
|
||||
const directAnthropicBaseUrl =
|
||||
settingsEnv['ANTHROPIC_BASE_URL'] ||
|
||||
(settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : '');
|
||||
const creds: TargetCredentials = {
|
||||
profile: profileInfo.name,
|
||||
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
|
||||
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||
baseUrl: directAnthropicBaseUrl,
|
||||
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '',
|
||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||
provider: resolveDroidProvider({
|
||||
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
|
||||
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'],
|
||||
baseUrl: directAnthropicBaseUrl,
|
||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||
}),
|
||||
reasoningOverride: droidReasoningOverride,
|
||||
|
||||
@@ -292,9 +292,9 @@ async function handleCreate(args: string[]): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 2: Base URL (use preset if provided)
|
||||
let baseUrl = parsedArgs.baseUrl || preset?.baseUrl;
|
||||
if (!baseUrl) {
|
||||
// Step 2: Base URL (use preset if provided; skip prompt for presets with empty baseUrl like anthropic)
|
||||
let baseUrl = parsedArgs.baseUrl ?? preset?.baseUrl ?? '';
|
||||
if (!baseUrl && !preset) {
|
||||
baseUrl = await InteractivePrompt.input(
|
||||
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
|
||||
{ validate: validateUrl }
|
||||
@@ -328,10 +328,21 @@ async function handleCreate(args: string[]): Promise<void> {
|
||||
// Show preset info
|
||||
console.log(info(`Using preset: ${preset.name}`));
|
||||
console.log(dim(` ${preset.description}`));
|
||||
console.log(dim(` Base URL: ${preset.baseUrl}`));
|
||||
if (preset.baseUrl) {
|
||||
console.log(dim(` Base URL: ${preset.baseUrl}`));
|
||||
} else {
|
||||
console.log(dim(` Auth: Native Anthropic API (x-api-key header)`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Auto-detect Anthropic direct API when user enters api.anthropic.com URL without preset
|
||||
if (baseUrl && baseUrl.includes('api.anthropic.com') && !preset) {
|
||||
console.log('');
|
||||
console.log(info('Anthropic Direct API detected. Base URL will be omitted for native auth.'));
|
||||
baseUrl = '';
|
||||
}
|
||||
|
||||
// OpenRouter detection: offer interactive model picker
|
||||
let openRouterModel: string | undefined;
|
||||
let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined;
|
||||
@@ -456,7 +467,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
||||
console.log('');
|
||||
console.log(info('Creating API profile...'));
|
||||
|
||||
const result = createApiProfile(name, baseUrl, apiKey, models, resolvedTarget);
|
||||
const result = createApiProfile(name, baseUrl || '', apiKey, models, resolvedTarget);
|
||||
|
||||
if (!result.success) {
|
||||
console.log(fail(`Failed to create API profile: ${result.error}`));
|
||||
@@ -946,6 +957,7 @@ async function showHelp(): Promise<void> {
|
||||
console.log(` ${color('ccs api create', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Quick setup with preset')}`);
|
||||
console.log(` ${color('ccs api create --preset anthropic', 'command')}`);
|
||||
console.log(` ${color('ccs api create --preset openrouter', 'command')}`);
|
||||
console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`);
|
||||
console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`);
|
||||
|
||||
@@ -55,7 +55,7 @@ export function formatExportLine(shell: ShellType, key: string, value: string):
|
||||
* can discover the model without additional configuration. */
|
||||
export function transformToOpenAI(envVars: Record<string, string>): Record<string, string> {
|
||||
const baseUrl = envVars['ANTHROPIC_BASE_URL'] || '';
|
||||
const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || '';
|
||||
const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || envVars['ANTHROPIC_API_KEY'] || '';
|
||||
const model = envVars['ANTHROPIC_MODEL'] || '';
|
||||
const result: Record<string, string> = {};
|
||||
if (apiKey) result['OPENAI_API_KEY'] = apiKey;
|
||||
|
||||
@@ -140,6 +140,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs ollama', 'Local Ollama (http://localhost:11434)'],
|
||||
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
|
||||
['', ''], // Spacer
|
||||
['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'],
|
||||
['ccs api create', 'Create custom API profile'],
|
||||
['ccs api discover --register', 'Discover/register orphan settings files'],
|
||||
['ccs api copy <src> <dest>', 'Duplicate API profile'],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export type PresetCategory = 'recommended' | 'alternative';
|
||||
|
||||
export const PROVIDER_PRESET_IDS = [
|
||||
'anthropic',
|
||||
'openrouter',
|
||||
'alibaba-coding-plan',
|
||||
'ollama',
|
||||
@@ -57,6 +58,20 @@ export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>>
|
||||
});
|
||||
|
||||
const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic (Direct API)',
|
||||
description: 'Use your own Anthropic API key (sk-ant-...)',
|
||||
baseUrl: '',
|
||||
defaultProfileName: 'anthropic',
|
||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||
apiKeyPlaceholder: 'sk-ant-api03-...',
|
||||
apiKeyHint: 'Get key at console.anthropic.com/settings/keys',
|
||||
category: 'recommended',
|
||||
requiresApiKey: true,
|
||||
badge: 'Direct',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
|
||||
@@ -166,3 +166,76 @@ export async function validateMiniMaxKey(
|
||||
timeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate Anthropic direct API key using x-api-key header (not Bearer) */
|
||||
export async function validateAnthropicKey(
|
||||
apiKey: string,
|
||||
timeoutMs = 2000
|
||||
): Promise<ValidationResult> {
|
||||
if (process.env.CCS_SKIP_PREFLIGHT === '1') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Anthropic API key not configured',
|
||||
suggestion: 'Set ANTHROPIC_API_KEY in your profile settings.json',
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const safeResolve = (result: ValidationResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: 'api.anthropic.com',
|
||||
port: 443,
|
||||
path: '/v1/models',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': 'CCS-Preflight/1.0',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
safeResolve({ valid: true });
|
||||
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
safeResolve({
|
||||
valid: false,
|
||||
error: 'Anthropic API key rejected',
|
||||
suggestion:
|
||||
'Your key may have expired. To fix:\n' +
|
||||
' 1. Go to console.anthropic.com/settings/keys and regenerate your API key\n' +
|
||||
' 2. Update ANTHROPIC_API_KEY in your profile settings.json\n' +
|
||||
' 3. Or run: ccs api create --preset anthropic',
|
||||
});
|
||||
} else {
|
||||
safeResolve({ valid: true });
|
||||
}
|
||||
|
||||
res.resume();
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
clearTimeout(timeoutId);
|
||||
safeResolve({ valid: true });
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
req.destroy();
|
||||
safeResolve({ valid: true });
|
||||
}, timeoutMs);
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -69,16 +69,17 @@ export class DelegationValidator {
|
||||
}
|
||||
|
||||
// Validate API key exists and is not default
|
||||
const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN;
|
||||
// Support both proxy mode (ANTHROPIC_AUTH_TOKEN) and native mode (ANTHROPIC_API_KEY)
|
||||
const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN || settings.env?.ANTHROPIC_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `API key not configured for ${profileName}`,
|
||||
suggestion:
|
||||
`Missing ANTHROPIC_AUTH_TOKEN in settings.\n\n` +
|
||||
`Missing ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY in settings.\n\n` +
|
||||
`Edit: ${settingsPath}\n` +
|
||||
`Set: env.ANTHROPIC_AUTH_TOKEN to your API key`,
|
||||
`Set: env.ANTHROPIC_AUTH_TOKEN (proxy) or env.ANTHROPIC_API_KEY (direct) to your API key`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
validateApiName,
|
||||
} from '../../api/services';
|
||||
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||
import { updateSettingsFile, parseTarget } from './route-helpers';
|
||||
import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -97,6 +97,9 @@ 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 normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
|
||||
const normalizedApiKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
||||
const allowsEmptyBaseUrl = isAnthropicDirectProfile(normalizedBaseUrl, normalizedApiKey);
|
||||
|
||||
const parsedTarget = parseTarget(target);
|
||||
if (target !== undefined && parsedTarget === null) {
|
||||
@@ -110,8 +113,10 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name || !baseUrl || !apiKey) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' });
|
||||
if (!name || !normalizedApiKey || (!normalizedBaseUrl && !allowsEmptyBaseUrl)) {
|
||||
res.status(400).json({
|
||||
error: 'Missing required fields: name, apiKey, and baseUrl for proxy profiles',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,8 +138,8 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
// Create profile using unified-config-aware service
|
||||
const result = createApiProfile(
|
||||
name,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
normalizedBaseUrl,
|
||||
normalizedApiKey,
|
||||
{
|
||||
default: model || '',
|
||||
opus: opusModel || model || '',
|
||||
@@ -355,6 +360,8 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
||||
const providerHint = req.body?.droidProvider ?? req.body?.provider;
|
||||
const parsedProvider = normalizeDroidProvider(providerHint);
|
||||
const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : baseUrl;
|
||||
const normalizedApiKey = typeof apiKey === 'string' ? apiKey.trim() : apiKey;
|
||||
|
||||
const parsedTarget = parseTarget(target);
|
||||
if (target !== undefined && parsedTarget === null) {
|
||||
@@ -375,11 +382,7 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
// Validate required fields if provided (prevent setting to empty)
|
||||
if (baseUrl !== undefined && !baseUrl.trim()) {
|
||||
res.status(400).json({ error: 'baseUrl cannot be empty' });
|
||||
return;
|
||||
}
|
||||
if (apiKey !== undefined && !apiKey.trim()) {
|
||||
if (normalizedApiKey !== undefined && !normalizedApiKey) {
|
||||
res.status(400).json({ error: 'apiKey cannot be empty' });
|
||||
return;
|
||||
}
|
||||
@@ -402,8 +405,8 @@ router.put('/:name', (req: Request, res: Response): void => {
|
||||
|
||||
if (hasSettingsUpdates) {
|
||||
updateSettingsFile(name, {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: normalizedApiKey,
|
||||
model,
|
||||
opusModel,
|
||||
sonnetModel,
|
||||
|
||||
@@ -82,6 +82,19 @@ function canonicalizeModelForProvider(
|
||||
return canonicalizeModelIdForProvider(value, provider);
|
||||
}
|
||||
|
||||
function isOpenRouterUrl(baseUrl: string): boolean {
|
||||
return baseUrl.toLowerCase().includes('openrouter.ai');
|
||||
}
|
||||
|
||||
export function isAnthropicDirectProfile(
|
||||
baseUrl: string | undefined | null,
|
||||
apiKey: string | undefined | null
|
||||
): boolean {
|
||||
const normalizedBaseUrl = baseUrl?.trim().toLowerCase() || '';
|
||||
const normalizedApiKey = apiKey?.trim() || '';
|
||||
return normalizedApiKey.startsWith('sk-ant-') || normalizedBaseUrl.includes('api.anthropic.com');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read config safely with fallback.
|
||||
* Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json).
|
||||
@@ -116,7 +129,11 @@ export function isConfigured(profileName: string, config: Config): boolean {
|
||||
if (!fs.existsSync(expandedPath)) return false;
|
||||
|
||||
const settings = loadSettings(expandedPath);
|
||||
return !!(settings.env?.ANTHROPIC_BASE_URL && settings.env?.ANTHROPIC_AUTH_TOKEN);
|
||||
// Proxy mode: BASE_URL + AUTH_TOKEN; Native mode: ANTHROPIC_API_KEY only
|
||||
return !!(
|
||||
(settings.env?.ANTHROPIC_BASE_URL && settings.env?.ANTHROPIC_AUTH_TOKEN) ||
|
||||
settings.env?.ANTHROPIC_API_KEY
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -165,11 +182,19 @@ export function createSettingsFile(
|
||||
baseUrl,
|
||||
model: canonicalModel,
|
||||
});
|
||||
const normalizedBaseUrl = baseUrl.trim();
|
||||
const normalizedApiKey = apiKey.trim();
|
||||
const isNative = isAnthropicDirectProfile(normalizedBaseUrl, normalizedApiKey);
|
||||
|
||||
const settings: Settings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isNative
|
||||
? { ANTHROPIC_API_KEY: normalizedApiKey }
|
||||
: {
|
||||
ANTHROPIC_BASE_URL: normalizedBaseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: normalizedApiKey,
|
||||
...(isOpenRouterUrl(normalizedBaseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
}),
|
||||
...(canonicalModel && { ANTHROPIC_MODEL: canonicalModel }),
|
||||
...(canonicalOpusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: canonicalOpusModel }),
|
||||
...(canonicalSonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: canonicalSonnetModel }),
|
||||
@@ -205,8 +230,23 @@ export function updateSettingsFile(
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
const currentBaseUrl = settings.env?.ANTHROPIC_BASE_URL?.trim() || '';
|
||||
const currentApiKey =
|
||||
settings.env?.ANTHROPIC_API_KEY?.trim() || settings.env?.ANTHROPIC_AUTH_TOKEN?.trim() || '';
|
||||
const nextBaseUrl = updates.baseUrl !== undefined ? updates.baseUrl.trim() : currentBaseUrl;
|
||||
const nextApiKey = updates.apiKey !== undefined ? updates.apiKey.trim() : currentApiKey;
|
||||
const isNative = isAnthropicDirectProfile(nextBaseUrl, nextApiKey);
|
||||
|
||||
if (!nextApiKey) {
|
||||
throw new ValidationError('apiKey cannot be empty', 'apiKey');
|
||||
}
|
||||
|
||||
if (!isNative && nextBaseUrl.length === 0) {
|
||||
throw new ValidationError('baseUrl cannot be empty', 'baseUrl');
|
||||
}
|
||||
|
||||
const providerForValidation =
|
||||
resolveProviderForModelCanonicalization(updates.baseUrl, updates.provider) ??
|
||||
resolveProviderForModelCanonicalization(nextBaseUrl, updates.provider) ??
|
||||
resolveProviderForModelCanonicalization(
|
||||
settings.env?.ANTHROPIC_BASE_URL,
|
||||
updates.provider ?? settings.env?.CCS_DROID_PROVIDER
|
||||
@@ -243,14 +283,19 @@ export function updateSettingsFile(
|
||||
throw new ValidationError(deniedReason, 'model');
|
||||
}
|
||||
|
||||
if (updates.baseUrl) {
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_BASE_URL = updates.baseUrl;
|
||||
}
|
||||
|
||||
if (updates.apiKey) {
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_AUTH_TOKEN = updates.apiKey;
|
||||
settings.env = settings.env || {};
|
||||
if (isNative) {
|
||||
delete settings.env.ANTHROPIC_BASE_URL;
|
||||
delete settings.env.ANTHROPIC_AUTH_TOKEN;
|
||||
settings.env.ANTHROPIC_API_KEY = nextApiKey;
|
||||
} else {
|
||||
settings.env.ANTHROPIC_BASE_URL = nextBaseUrl;
|
||||
settings.env.ANTHROPIC_AUTH_TOKEN = nextApiKey;
|
||||
if (isOpenRouterUrl(nextBaseUrl)) {
|
||||
settings.env.ANTHROPIC_API_KEY = '';
|
||||
} else {
|
||||
delete settings.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.model !== undefined) {
|
||||
@@ -299,7 +344,7 @@ export function updateSettingsFile(
|
||||
settings.env = settings.env || {};
|
||||
const resolvedProvider = resolveDroidProvider({
|
||||
provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER,
|
||||
baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL,
|
||||
baseUrl: nextBaseUrl,
|
||||
model: canonicalModel ?? settings.env.ANTHROPIC_MODEL,
|
||||
});
|
||||
settings.env.CCS_DROID_PROVIDER = resolvedProvider;
|
||||
|
||||
@@ -366,6 +366,9 @@ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as cons
|
||||
/** Check if settings have required fields (returns missing list for warnings) */
|
||||
function checkRequiredEnvVars(settings: Settings): string[] {
|
||||
const env = settings?.env || {};
|
||||
if (env.ANTHROPIC_API_KEY?.trim() && !env.ANTHROPIC_BASE_URL?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createApiProfile } from '../../../src/api/services/profile-writer';
|
||||
|
||||
describe('profile-writer Anthropic direct', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-writer-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates native env structure for sk-ant- API key', () => {
|
||||
const result = createApiProfile(
|
||||
'anthropic-test',
|
||||
'',
|
||||
'sk-ant-api03-testkey123',
|
||||
{ default: 'claude-sonnet-4-5-20250929', opus: 'claude-opus-4-5-20251101', sonnet: 'claude-sonnet-4-5-20250929', haiku: 'claude-haiku-4-5-20251001' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'anthropic-test.settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
|
||||
// Native mode: ANTHROPIC_API_KEY present, no BASE_URL or AUTH_TOKEN
|
||||
expect(settings.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-testkey123');
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929');
|
||||
});
|
||||
|
||||
it('creates native env structure for api.anthropic.com URL', () => {
|
||||
const result = createApiProfile(
|
||||
'anthropic-url',
|
||||
'https://api.anthropic.com',
|
||||
'some-key-123',
|
||||
{ default: 'claude-sonnet-4-5-20250929', opus: 'claude-sonnet-4-5-20250929', sonnet: 'claude-sonnet-4-5-20250929', haiku: 'claude-sonnet-4-5-20250929' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'anthropic-url.settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
|
||||
expect(settings.env.ANTHROPIC_API_KEY).toBe('some-key-123');
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates proxy env structure for non-Anthropic keys', () => {
|
||||
const result = createApiProfile(
|
||||
'proxy-test',
|
||||
'https://api.z.ai/api/anthropic',
|
||||
'ghp_testkey123',
|
||||
{ default: 'glm-5', opus: 'glm-5', sonnet: 'glm-5', haiku: 'glm-5' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'proxy-test.settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
|
||||
// Proxy mode: BASE_URL + AUTH_TOKEN, no ANTHROPIC_API_KEY
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai/api/anthropic');
|
||||
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('ghp_testkey123');
|
||||
expect(settings.env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => {
|
||||
const result = createApiProfile(
|
||||
'openrouter-test',
|
||||
'https://openrouter.ai/api',
|
||||
'sk-or-testkey',
|
||||
{ default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'openrouter-test.settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
|
||||
// OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api');
|
||||
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey');
|
||||
expect(settings.env.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,18 @@ describe('env-command', () => {
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('maps native Anthropic API key to OpenAI format', () => {
|
||||
const result = transformToOpenAI({
|
||||
ANTHROPIC_API_KEY: 'sk-ant-api03-test',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
OPENAI_API_KEY: 'sk-ant-api03-test',
|
||||
OPENAI_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
});
|
||||
});
|
||||
|
||||
it('only extracts relevant vars', () => {
|
||||
const result = transformToOpenAI({
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:8317',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import profileRoutes from '../../../src/web-server/routes/profile-routes';
|
||||
|
||||
describe('profile-routes Anthropic direct', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profiles', profileRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates direct Anthropic profiles without baseUrl', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/profiles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'anthropic-direct',
|
||||
baseUrl: '',
|
||||
apiKey: 'sk-ant-api03-create',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'anthropic-direct.settings.json');
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-create');
|
||||
expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates proxy profiles into direct Anthropic mode', async () => {
|
||||
const createResponse = await fetch(`${baseUrl}/api/profiles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'anthropic-switch',
|
||||
baseUrl: 'https://api.z.ai/api/anthropic',
|
||||
apiKey: 'proxy-token',
|
||||
model: 'glm-5',
|
||||
}),
|
||||
});
|
||||
expect(createResponse.status).toBe(201);
|
||||
|
||||
const updateResponse = await fetch(`${baseUrl}/api/profiles/anthropic-switch`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
baseUrl: '',
|
||||
apiKey: 'sk-ant-api03-update',
|
||||
}),
|
||||
});
|
||||
expect(updateResponse.status).toBe(200);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'anthropic-switch.settings.json');
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-update');
|
||||
expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -211,4 +211,52 @@ describe('route-helpers AGY denylist', () => {
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus');
|
||||
});
|
||||
|
||||
it('creates native Anthropic settings without base URL', () => {
|
||||
createSettingsFile('anthropic-direct', '', 'sk-ant-api03-test', {
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'anthropic-direct.settings.json');
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-test');
|
||||
expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it('switches proxy settings to native Anthropic mode on update', () => {
|
||||
const settingsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
const settingsPath = path.join(settingsDir, 'anthropic-update.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
updateSettingsFile('anthropic-update', {
|
||||
baseUrl: '',
|
||||
apiKey: 'sk-ant-api03-test',
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-test');
|
||||
expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -51,12 +51,18 @@ import type { CategorizedModel } from '@/lib/openrouter-types';
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
import i18n from '@/lib/i18n';
|
||||
|
||||
const optionalUrlSchema = z
|
||||
.string()
|
||||
.refine((value) => value.trim().length === 0 || z.string().url().safeParse(value).success, {
|
||||
message: 'Invalid URL format',
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'),
|
||||
baseUrl: z.string().url('Invalid URL format'),
|
||||
baseUrl: optionalUrlSchema,
|
||||
apiKey: z.string(), // Validation handled conditionally in onSubmit
|
||||
model: z.string().optional(),
|
||||
opusModel: z.string().optional(),
|
||||
@@ -389,9 +395,7 @@ export function ProfileCreateDialog({
|
||||
|
||||
{/* Base URL - always editable, pre-filled from preset */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="baseUrl">
|
||||
API Base URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
{...register('baseUrl')}
|
||||
@@ -406,7 +410,9 @@ export function ProfileCreateDialog({
|
||||
</div>
|
||||
) : currentPreset ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pre-filled from {currentPreset.name}. You can customize if needed.
|
||||
{currentPreset.baseUrl
|
||||
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
||||
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -19,13 +19,18 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||
const optionalUrlSchema = z
|
||||
.string()
|
||||
.refine((value) => value.trim().length === 0 || z.string().url().safeParse(value).success, {
|
||||
message: 'Invalid URL',
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid profile name'),
|
||||
baseUrl: z.string().url('Invalid URL'),
|
||||
baseUrl: optionalUrlSchema,
|
||||
apiKey: z.string().min(10, 'API key must be at least 10 characters'),
|
||||
model: z.string().optional(),
|
||||
opusModel: z.string().optional(),
|
||||
|
||||
Reference in new issue
Block a user