fix(api): complete anthropic direct profile support

This commit is contained in:
Tam Nhu Tran
2026-03-07 15:58:36 +07:00
parent fedb4d4cde
commit 1f29fa0b6a
11 changed files with 270 additions and 33 deletions
+1
View File
@@ -101,6 +101,7 @@ function createSettingsFile(
}, },
}; };
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
// Inject WebSearch hooks into profile settings // Inject WebSearch hooks into profile settings
+5 -2
View File
@@ -1131,14 +1131,17 @@ async function main(): Promise<void> {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1); process.exit(1);
} }
const directAnthropicBaseUrl =
settingsEnv['ANTHROPIC_BASE_URL'] ||
(settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : '');
const creds: TargetCredentials = { const creds: TargetCredentials = {
profile: profileInfo.name, profile: profileInfo.name,
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', baseUrl: directAnthropicBaseUrl,
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '',
model: settingsEnv['ANTHROPIC_MODEL'], model: settingsEnv['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({ provider: resolveDroidProvider({
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'], baseUrl: directAnthropicBaseUrl,
model: settingsEnv['ANTHROPIC_MODEL'], model: settingsEnv['ANTHROPIC_MODEL'],
}), }),
reasoningOverride: droidReasoningOverride, reasoningOverride: droidReasoningOverride,
+1 -1
View File
@@ -55,7 +55,7 @@ export function formatExportLine(shell: ShellType, key: string, value: string):
* can discover the model without additional configuration. */ * can discover the model without additional configuration. */
export function transformToOpenAI(envVars: Record<string, string>): Record<string, string> { export function transformToOpenAI(envVars: Record<string, string>): Record<string, string> {
const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; 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 model = envVars['ANTHROPIC_MODEL'] || '';
const result: Record<string, string> = {}; const result: Record<string, string> = {};
if (apiKey) result['OPENAI_API_KEY'] = apiKey; if (apiKey) result['OPENAI_API_KEY'] = apiKey;
+15 -12
View File
@@ -14,7 +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 { normalizeDroidProvider } from '../../targets/droid-provider'; import { normalizeDroidProvider } from '../../targets/droid-provider';
import { updateSettingsFile, parseTarget } from './route-helpers'; import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers';
const router = Router(); const router = Router();
@@ -50,6 +50,9 @@ 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 providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint); 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); const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) { if (target !== undefined && parsedTarget === null) {
@@ -63,8 +66,10 @@ router.post('/', (req: Request, res: Response): void => {
return; return;
} }
if (!name || !baseUrl || !apiKey) { if (!name || !normalizedApiKey || (!normalizedBaseUrl && !allowsEmptyBaseUrl)) {
res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); res.status(400).json({
error: 'Missing required fields: name, apiKey, and baseUrl for proxy profiles',
});
return; return;
} }
@@ -86,8 +91,8 @@ router.post('/', (req: Request, res: Response): void => {
// Create profile using unified-config-aware service // Create profile using unified-config-aware service
const result = createApiProfile( const result = createApiProfile(
name, name,
baseUrl, normalizedBaseUrl,
apiKey, normalizedApiKey,
{ {
default: model || '', default: model || '',
opus: opusModel || model || '', opus: opusModel || model || '',
@@ -119,6 +124,8 @@ router.put('/:name', (req: Request, res: Response): void => {
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 providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint); const parsedProvider = normalizeDroidProvider(providerHint);
const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : baseUrl;
const normalizedApiKey = typeof apiKey === 'string' ? apiKey.trim() : apiKey;
const parsedTarget = parseTarget(target); const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) { if (target !== undefined && parsedTarget === null) {
@@ -139,11 +146,7 @@ router.put('/:name', (req: Request, res: Response): void => {
} }
// Validate required fields if provided (prevent setting to empty) // Validate required fields if provided (prevent setting to empty)
if (baseUrl !== undefined && !baseUrl.trim()) { if (normalizedApiKey !== undefined && !normalizedApiKey) {
res.status(400).json({ error: 'baseUrl cannot be empty' });
return;
}
if (apiKey !== undefined && !apiKey.trim()) {
res.status(400).json({ error: 'apiKey cannot be empty' }); res.status(400).json({ error: 'apiKey cannot be empty' });
return; return;
} }
@@ -166,8 +169,8 @@ router.put('/:name', (req: Request, res: Response): void => {
if (hasSettingsUpdates) { if (hasSettingsUpdates) {
updateSettingsFile(name, { updateSettingsFile(name, {
baseUrl, baseUrl: normalizedBaseUrl,
apiKey, apiKey: normalizedApiKey,
model, model,
opusModel, opusModel,
sonnetModel, sonnetModel,
+53 -12
View File
@@ -82,6 +82,19 @@ function canonicalizeModelForProvider(
return canonicalizeModelIdForProvider(value, provider); 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. * Read config safely with fallback.
* Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). * Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json).
@@ -169,11 +182,19 @@ export function createSettingsFile(
baseUrl, baseUrl,
model: canonicalModel, model: canonicalModel,
}); });
const normalizedBaseUrl = baseUrl.trim();
const normalizedApiKey = apiKey.trim();
const isNative = isAnthropicDirectProfile(normalizedBaseUrl, normalizedApiKey);
const settings: Settings = { const settings: Settings = {
env: { env: {
ANTHROPIC_BASE_URL: baseUrl, ...(isNative
ANTHROPIC_AUTH_TOKEN: apiKey, ? { ANTHROPIC_API_KEY: normalizedApiKey }
: {
ANTHROPIC_BASE_URL: normalizedBaseUrl,
ANTHROPIC_AUTH_TOKEN: normalizedApiKey,
...(isOpenRouterUrl(normalizedBaseUrl) && { ANTHROPIC_API_KEY: '' }),
}),
...(canonicalModel && { ANTHROPIC_MODEL: canonicalModel }), ...(canonicalModel && { ANTHROPIC_MODEL: canonicalModel }),
...(canonicalOpusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: canonicalOpusModel }), ...(canonicalOpusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: canonicalOpusModel }),
...(canonicalSonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: canonicalSonnetModel }), ...(canonicalSonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: canonicalSonnetModel }),
@@ -209,8 +230,23 @@ export function updateSettingsFile(
} }
const settings = loadSettings(settingsPath); 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 = const providerForValidation =
resolveProviderForModelCanonicalization(updates.baseUrl, updates.provider) ?? resolveProviderForModelCanonicalization(nextBaseUrl, updates.provider) ??
resolveProviderForModelCanonicalization( resolveProviderForModelCanonicalization(
settings.env?.ANTHROPIC_BASE_URL, settings.env?.ANTHROPIC_BASE_URL,
updates.provider ?? settings.env?.CCS_DROID_PROVIDER updates.provider ?? settings.env?.CCS_DROID_PROVIDER
@@ -247,14 +283,19 @@ export function updateSettingsFile(
throw new ValidationError(deniedReason, 'model'); throw new ValidationError(deniedReason, 'model');
} }
if (updates.baseUrl) { settings.env = settings.env || {};
settings.env = settings.env || {}; if (isNative) {
settings.env.ANTHROPIC_BASE_URL = updates.baseUrl; delete settings.env.ANTHROPIC_BASE_URL;
} delete settings.env.ANTHROPIC_AUTH_TOKEN;
settings.env.ANTHROPIC_API_KEY = nextApiKey;
if (updates.apiKey) { } else {
settings.env = settings.env || {}; settings.env.ANTHROPIC_BASE_URL = nextBaseUrl;
settings.env.ANTHROPIC_AUTH_TOKEN = updates.apiKey; 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) { if (updates.model !== undefined) {
@@ -303,7 +344,7 @@ export function updateSettingsFile(
settings.env = settings.env || {}; settings.env = settings.env || {};
const resolvedProvider = resolveDroidProvider({ const resolvedProvider = resolveDroidProvider({
provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER, provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER,
baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL, baseUrl: nextBaseUrl,
model: canonicalModel ?? settings.env.ANTHROPIC_MODEL, model: canonicalModel ?? settings.env.ANTHROPIC_MODEL,
}); });
settings.env.CCS_DROID_PROVIDER = resolvedProvider; settings.env.CCS_DROID_PROVIDER = resolvedProvider;
+3
View File
@@ -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) */ /** Check if settings have required fields (returns missing list for warnings) */
function checkRequiredEnvVars(settings: Settings): string[] { function checkRequiredEnvVars(settings: Settings): string[] {
const env = settings?.env || {}; 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()); return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
} }
+12
View File
@@ -134,6 +134,18 @@ describe('env-command', () => {
expect(result).toEqual({}); 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', () => { it('only extracts relevant vars', () => {
const result = transformToOpenAI({ const result = transformToOpenAI({
ANTHROPIC_BASE_URL: 'http://localhost:8317', 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_SONNET_MODEL).toBe('deepseek-v3.2');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); 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 type { CliTarget } from '@/lib/api-client';
import i18n from '@/lib/i18n'; 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({ const schema = z.object({
name: z name: z
.string() .string()
.min(1, 'Name is required') .min(1, 'Name is required')
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), .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 apiKey: z.string(), // Validation handled conditionally in onSubmit
model: z.string().optional(), model: z.string().optional(),
opusModel: z.string().optional(), opusModel: z.string().optional(),
@@ -389,9 +395,7 @@ export function ProfileCreateDialog({
{/* Base URL - always editable, pre-filled from preset */} {/* Base URL - always editable, pre-filled from preset */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="baseUrl"> <Label htmlFor="baseUrl">API Base URL</Label>
API Base URL <span className="text-destructive">*</span>
</Label>
<Input <Input
id="baseUrl" id="baseUrl"
{...register('baseUrl')} {...register('baseUrl')}
@@ -406,7 +410,9 @@ export function ProfileCreateDialog({
</div> </div>
) : currentPreset ? ( ) : currentPreset ? (
<p className="text-xs text-muted-foreground"> <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>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -19,13 +19,18 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
const optionalUrlSchema = z
.string()
.refine((value) => value.trim().length === 0 || z.string().url().safeParse(value).success, {
message: 'Invalid URL',
});
const schema = z.object({ const schema = z.object({
name: z name: z
.string() .string()
.min(1, 'Name is required') .min(1, 'Name is required')
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid profile name'), .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'), apiKey: z.string().min(10, 'API key must be at least 10 characters'),
model: z.string().optional(), model: z.string().optional(),
opusModel: z.string().optional(), opusModel: z.string().optional(),