Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/site/content/docs/en/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export ANTHROPIC_API_KEY="sk-ant-..."

> **Note:** Configurations changed via `claude config set` or Claude Code's `/config` command are **not recognized by CodePilot**. CodePilot only reads shell environment variables and does not share Claude Code CLI's internal configuration. If you switched accounts/keys in the CLI via `cc switch` or similar methods, you need to manually reconfigure the corresponding key in CodePilot's **Settings > Providers**.

> If you mainly manage Claude Code through **cc-switch**, you can enable **Settings > General > Enable cc-switch Compatibility Mode**. This keeps the built-in `env` provider as a valid default target and remembers the selected effort level for each provider, so new chats start with the same defaults more reliably.

> After modifying environment variables, you need to **restart CodePilot** for changes to take effect.

### 2. Manually Adding Providers
Expand Down
2 changes: 2 additions & 0 deletions apps/site/content/docs/zh/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export ANTHROPIC_API_KEY="sk-ant-..."

> **注意:** 通过 `claude config set` 或 Claude Code 的 `/config` 命令切换的配置**不会被 CodePilot 识别**。CodePilot 只读取 shell 环境变量,不共享 Claude Code CLI 的内部配置。如果你在 CLI 中通过 `cc switch` 或类似方式切换了账号/密钥,需要在 CodePilot 的 **设置 > 服务商** 中重新手动配置对应的密钥。

> 如果你主要通过 **cc-switch** 管理 Claude Code,可以打开 **设置 > 通用 > 开启 cc-switch 兼容模式**。这样会把内置 `env` 服务商继续视为有效默认目标,并记住各服务商所选的 effort,让新对话更稳定地沿用同一套默认值。

> 修改环境变量后需要**重启 CodePilot** 才能生效。

### 2. 手动添加服务商
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/unit/cc-switch-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import {
buildCcSwitchCompatGlobalDefaultPayload,
shouldPersistCcSwitchCompatGlobalDefault,
} from '../../lib/cc-switch-compat';

describe('cc-switch compat helpers', () => {
it('only persists global defaults for the built-in Claude Code provider', () => {
assert.equal(shouldPersistCcSwitchCompatGlobalDefault(true, 'env', 'sonnet'), true);
assert.equal(shouldPersistCcSwitchCompatGlobalDefault(true, 'some-db-provider', 'sonnet'), false);
assert.equal(shouldPersistCcSwitchCompatGlobalDefault(false, 'env', 'sonnet'), false);
assert.equal(shouldPersistCcSwitchCompatGlobalDefault(true, 'env', ''), false);
});

it('builds a payload that reuses the env/Claude Code provider as the global default target', () => {
assert.deepEqual(
buildCcSwitchCompatGlobalDefaultPayload('env', 'opus'),
{
providerId: '__global__',
options: {
default_model: 'opus',
default_model_provider: 'env',
legacy_default_provider_id: 'env',
},
},
);
});
});
95 changes: 95 additions & 0 deletions src/__tests__/unit/legacy-provider-placeholder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { filterVisibleProviders, isLegacyMigratedDefaultPlaceholder } from '@/lib/legacy-provider-placeholder';
import { createProvider, deleteProvider } from '@/lib/db';
import type { ApiProvider } from '@/types';

function buildProvider(overrides: Partial<ApiProvider> = {}): ApiProvider {
return {
id: 'provider-id',
name: 'Default',
provider_type: 'anthropic',
protocol: '',
base_url: '',
api_key: '',
is_active: 0,
sort_order: 0,
extra_env: '{}',
headers_json: '{}',
env_overrides_json: '{}',
role_models_json: '{}',
options_json: '{}',
notes: 'Migrated from settings',
created_at: '2026-04-05 00:00:00',
updated_at: '2026-04-05 00:00:00',
...overrides,
};
}

describe('legacy migrated provider placeholder', () => {
it('detects the empty migrated Default provider', () => {
assert.equal(isLegacyMigratedDefaultPlaceholder(buildProvider()), true);
});

it('keeps user-managed providers visible', () => {
assert.equal(
isLegacyMigratedDefaultPlaceholder(buildProvider({ api_key: 'sk-real-key' })),
false,
);
assert.equal(
isLegacyMigratedDefaultPlaceholder(buildProvider({ notes: 'Custom provider' })),
false,
);
});

it('filters only the placeholder entry', () => {
const visibleProvider = buildProvider({
id: 'real-provider',
name: 'OpenRouter',
provider_type: 'openrouter',
protocol: 'openrouter',
base_url: 'https://openrouter.ai/api/v1',
api_key: 'sk-visible',
notes: '',
});

assert.deepEqual(
filterVisibleProviders([buildProvider(), visibleProvider]).map(provider => provider.id),
['real-provider'],
);
});

it('does not expose the placeholder through provider APIs', async () => {
const placeholder = createProvider({
name: 'Default',
provider_type: 'anthropic',
protocol: '',
base_url: '',
api_key: '',
extra_env: '{}',
notes: 'Migrated from settings',
});

try {
const providersRoute = await import('@/app/api/providers/route');
const providersResponse = await providersRoute.GET();
const providersData = await providersResponse.json() as { providers: ApiProvider[] };
assert.equal(
providersData.providers.some(provider => provider.id === placeholder.id),
false,
);

const modelsRoute = await import('@/app/api/providers/models/route');
const modelsResponse = await modelsRoute.GET();
const modelsData = await modelsResponse.json() as {
groups: Array<{ provider_id: string; provider_name: string }>;
};
assert.equal(
modelsData.groups.some(group => group.provider_id === placeholder.id || group.provider_name === 'Default'),
false,
);
} finally {
deleteProvider(placeholder.id);
}
});
});
83 changes: 83 additions & 0 deletions src/__tests__/unit/provider-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { NextRequest } from 'next/server';

import { getProviderOptions, setProviderOptions, getSetting, setSetting } from '../../lib/db';

describe('Provider options', () => {
let savedThinking: string | undefined;
let savedContext1m: string | undefined;
let savedEffort: string | undefined;

beforeEach(() => {
savedThinking = getSetting('thinking_mode');
savedContext1m = getSetting('context_1m');
savedEffort = getSetting('effort');
setSetting('thinking_mode', '');
setSetting('context_1m', '');
setSetting('effort', '');
});

afterEach(() => {
setSetting('thinking_mode', savedThinking || '');
setSetting('context_1m', savedContext1m || '');
setSetting('effort', savedEffort || '');
});

it('env provider options round-trip effort persistence', () => {
setProviderOptions('env', {
thinking_mode: 'enabled',
context_1m: true,
effort: 'max',
});

const options = getProviderOptions('env');
assert.equal(options.thinking_mode, 'enabled');
assert.equal(options.context_1m, true);
assert.equal(options.effort, 'max');
});

it('drops invalid stored effort values when rehydrating env provider options', () => {
setSetting('effort', 'invalid-effort');

const options = getProviderOptions('env');
assert.equal(options.effort, undefined);
});

it('rejects invalid effort values in the provider options route', async () => {
const { PUT } = await import('../../app/api/providers/options/route');
const request = new NextRequest('http://localhost/api/providers/options', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: 'env',
options: { effort: 'invalid-effort' },
}),
});

const response = await PUT(request);
assert.equal(response.status, 400);
});

it('treats null effort as clearing the stored env effort', async () => {
setSetting('effort', 'max');

const { PUT } = await import('../../app/api/providers/options/route');
const request = new NextRequest('http://localhost/api/providers/options', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: 'env',
options: { effort: null },
}),
});

const response = await PUT(request);
const data = await response.json() as { options: { effort?: string } };

assert.equal(response.status, 200);
assert.equal(data.options.effort, undefined);
assert.equal(getProviderOptions('env').effort, undefined);
assert.equal(getSetting('effort'), '');
});
});
57 changes: 57 additions & 0 deletions src/__tests__/unit/stale-default-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,23 @@ describe('Stale default_provider_id cleanup', () => {
// Save and restore original default + global default model provider
let originalDefault: string | undefined;
let originalGlobalProvider: string | undefined;
let originalCompatMode: string | undefined;

beforeEach(() => {
originalDefault = getDefaultProviderId();
originalGlobalProvider = getSetting('global_default_model_provider') || undefined;
originalCompatMode = getSetting('cc_switch_compat_mode') || undefined;
// Clear global_default_model_provider so these tests exercise the legacy path
setSetting('global_default_model_provider', '');
setSetting('cc_switch_compat_mode', '');
cleanupTestProviders();
});

afterEach(() => {
cleanupTestProviders();
// Restore originals
setSetting('global_default_model_provider', originalGlobalProvider || '');
setSetting('cc_switch_compat_mode', originalCompatMode || '');
if (originalDefault) {
setDefaultProviderId(originalDefault);
}
Expand Down Expand Up @@ -150,6 +154,59 @@ describe('Stale default_provider_id cleanup', () => {
});
});

describe('providers/models route with cc-switch compat mode', () => {
it('keeps env as default_provider_id when compat mode is enabled', async () => {
const providerId = createTestProvider('__test_real_provider');
setDefaultProviderId('env');
setSetting('cc_switch_compat_mode', 'true');

const { GET } = await import('../../app/api/providers/models/route');
const response = await GET();
const data = await response.json() as { default_provider_id: string };

assert.equal(data.default_provider_id, 'env');
assert.equal(getDefaultProviderId(), 'env');

deleteProvider(providerId);
});

it('exposes effort support metadata for built-in env models even without SDK cache', async () => {
setDefaultProviderId('env');
setSetting('cc_switch_compat_mode', 'true');

const { GET } = await import('../../app/api/providers/models/route');
const response = await GET();
const data = await response.json() as {
groups: Array<{
provider_id: string;
models: Array<{ value: string; supportsEffort?: boolean; supportedEffortLevels?: string[] }>;
}>;
};

const envGroup = data.groups.find(group => group.provider_id === 'env');
const sonnet = envGroup?.models.find(model => model.value === 'sonnet');

assert.ok(envGroup, 'env group should exist');
assert.equal(sonnet?.supportsEffort, true);
assert.deepEqual(sonnet?.supportedEffortLevels, ['low', 'medium', 'high', 'max']);
});

it('keeps env as the default when compat mode is disabled and env is still selectable', async () => {
const providerId = createTestProvider('__test_real_provider');
setDefaultProviderId('env');
setSetting('cc_switch_compat_mode', '');

const { GET } = await import('../../app/api/providers/models/route');
const response = await GET();
const data = await response.json() as { default_provider_id: string };

assert.equal(data.default_provider_id, 'env');
assert.equal(getDefaultProviderId(), 'env');

deleteProvider(providerId);
});
});

describe('error-classifier categorizes stale default correctly', () => {
it('classifyError produces PROCESS_CRASH for exit code 1', async () => {
const { classifyError } = await import('../../lib/error-classifier');
Expand Down
19 changes: 14 additions & 5 deletions src/app/api/providers/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { NextResponse } from 'next/server';
import { getAllProviders, getDefaultProviderId, setDefaultProviderId, getProvider, getModelsForProvider, getSetting } from '@/lib/db';
import { getContextWindow } from '@/lib/model-context';
import { getDefaultModelsForProvider, inferProtocolFromLegacy, findPresetForLegacy } from '@/lib/provider-catalog';
import { filterVisibleProviders } from '@/lib/legacy-provider-placeholder';
import type { Protocol } from '@/lib/provider-catalog';
import type { ErrorResponse, ProviderModelGroup } from '@/types';

// Default Claude model options (for the built-in 'env' provider)
const DEFAULT_MODELS = [
{ value: 'sonnet', label: 'Sonnet 4.6' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
{ value: 'sonnet', label: 'Sonnet 4.6', supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high', 'max'] },
{ value: 'opus', label: 'Opus 4.6', supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high', 'max'] },
{ value: 'haiku', label: 'Haiku 4.5', supportsEffort: true, supportedEffortLevels: ['low', 'medium', 'high', 'max'] },
];

interface ModelEntry {
Expand Down Expand Up @@ -41,8 +42,9 @@ const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']);

export async function GET() {
try {
const providers = getAllProviders();
const providers = filterVisibleProviders(getAllProviders());
const groups: ProviderModelGroup[] = [];
const ccSwitchCompatMode = getSetting('cc_switch_compat_mode') === 'true';

// Always show the built-in Claude Code provider group.
// Mark it as sdkProxyOnly if no direct API credentials exist — in that case
Expand Down Expand Up @@ -192,11 +194,18 @@ export async function GET() {

// Determine default provider — auto-heal stale references on read
let defaultProviderId = getDefaultProviderId();
if (defaultProviderId && !getProvider(defaultProviderId)) {
const defaultIsCompatEnv = ccSwitchCompatMode && defaultProviderId === 'env';
const hasVisibleDefaultProvider = !!defaultProviderId && groups.some(g => g.provider_id === defaultProviderId);
if (defaultProviderId && !defaultIsCompatEnv && !hasVisibleDefaultProvider && !getProvider(defaultProviderId)) {
// Stale default (provider was deleted). Fix it now.
const firstValid = groups.find(g => g.provider_id !== 'env');
defaultProviderId = firstValid?.provider_id || '';
setDefaultProviderId(defaultProviderId);
} else if (defaultProviderId && !defaultIsCompatEnv && !hasVisibleDefaultProvider) {
// Hidden legacy placeholder or otherwise non-selectable provider.
const firstValid = groups.find(g => g.provider_id !== 'env');
defaultProviderId = firstValid?.provider_id || 'env';
setDefaultProviderId(defaultProviderId);
}
defaultProviderId = defaultProviderId || groups[0]?.provider_id || '';

Expand Down
Loading