Compare commits

...

1 Commits

Author SHA1 Message Date
Arvin Xu 5d66af8156 feat(claude-code): support API mode with LobeHub provider binding
Route Claude Code CLI to a LobeHub provider + model instead of its
subscription login. Adds an auth-mode toggle and Provider/Model/Fast
Model selectors on the agent profile, an inline model picker in the
chat input, and spawn-time env injection
(ANTHROPIC_API_KEY / _BASE_URL / _MODEL / _SMALL_FAST_MODEL) for
providers with sdkType='anthropic'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:40:08 +08:00
18 changed files with 800 additions and 48 deletions
+1
View File
@@ -196,6 +196,7 @@
"groupWizard.searchTemplates": "Search templates...",
"groupWizard.title": "Create Group",
"groupWizard.useTemplate": "Use Template",
"heteroAgent.apiMode.providerNotFound": "The provider \"{{providerId}}\" is not configured. Open provider settings to set an API key first.",
"heteroAgent.cloudNotConfigured.action": "Configure",
"heteroAgent.cloudNotConfigured.desc": "Configure your Claude Code token in agent profile to start sending messages.",
"heteroAgent.cloudNotConfigured.title": "Cloud credentials required",
+9 -1
View File
@@ -285,7 +285,15 @@
"header.sessionWithName": "Session Settings · {{name}}",
"header.title": "Settings",
"heterogeneousStatus.account.label": "Account",
"heterogeneousStatus.auth.api": "API",
"heterogeneousStatus.apiMode.configureProvider": "Go to provider settings",
"heterogeneousStatus.apiMode.model": "Model",
"heterogeneousStatus.apiMode.modelPlaceholder": "Select a model",
"heterogeneousStatus.apiMode.noProviders": "No provider with an Anthropic-compatible endpoint is configured.",
"heterogeneousStatus.apiMode.provider": "Provider",
"heterogeneousStatus.apiMode.providerPlaceholder": "Select a provider",
"heterogeneousStatus.apiMode.smallFastModel": "Fast Model",
"heterogeneousStatus.apiMode.smallFastModelPlaceholder": "Optional — for background tasks",
"heterogeneousStatus.auth.api": "Custom API",
"heterogeneousStatus.auth.label": "Auth Method",
"heterogeneousStatus.auth.subscription": "Subscription",
"heterogeneousStatus.cloud.githubDesc": "Select a GitHub OAuth credential to allow the sandbox to clone your private repositories.",
+1
View File
@@ -196,6 +196,7 @@
"groupWizard.searchTemplates": "搜索模板…",
"groupWizard.title": "创建群组",
"groupWizard.useTemplate": "使用模板",
"heteroAgent.apiMode.providerNotFound": "服务商 \"{{providerId}}\" 尚未配置,请先到服务商设置填写 API Key。",
"heteroAgent.cloudNotConfigured.action": "立即配置",
"heteroAgent.cloudNotConfigured.desc": "请在 Agent 资料中配置 Claude Code Token,以开始发送消息。",
"heteroAgent.cloudNotConfigured.title": "需要配置云端凭证",
+10 -2
View File
@@ -285,8 +285,16 @@
"header.sessionWithName": "会话设置 · {{name}}",
"header.title": "设置",
"heterogeneousStatus.account.label": "账号",
"heterogeneousStatus.auth.api": "API",
"heterogeneousStatus.auth.label": "认证方式",
"heterogeneousStatus.apiMode.configureProvider": "前往服务商设置",
"heterogeneousStatus.apiMode.model": "模型",
"heterogeneousStatus.apiMode.modelPlaceholder": "选择模型",
"heterogeneousStatus.apiMode.noProviders": "尚未配置支持 Anthropic 协议的服务商",
"heterogeneousStatus.apiMode.provider": "服务商",
"heterogeneousStatus.apiMode.providerPlaceholder": "选择服务商",
"heterogeneousStatus.apiMode.smallFastModel": "小模型",
"heterogeneousStatus.apiMode.smallFastModelPlaceholder": "可选 · 后台任务使用",
"heterogeneousStatus.auth.api": "自定义 API",
"heterogeneousStatus.auth.label": "授权方式",
"heterogeneousStatus.auth.subscription": "订阅",
"heterogeneousStatus.cloud.githubDesc": "选择一个 GitHub OAuth 凭据,以允许沙箱克隆您的私有存储库。",
"heterogeneousStatus.cloud.githubLabel": "GitHub 连接",
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { buildClaudeCodeApiEnv } from './claudeCodeEnv';
describe('buildClaudeCodeApiEnv', () => {
describe('anthropic sdkType', () => {
it('maps apiKey + model into ANTHROPIC_API_KEY + ANTHROPIC_MODEL', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'sk-ant-test' },
model: 'claude-sonnet-4-5',
sdkType: 'anthropic',
});
expect(result.error).toBeUndefined();
expect(result.env).toEqual({
ANTHROPIC_API_KEY: 'sk-ant-test',
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
});
});
it('adds ANTHROPIC_BASE_URL when keyVaults.baseURL is provided', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'key', baseURL: 'https://api.moonshot.cn/anthropic' },
model: 'kimi-k2',
sdkType: 'anthropic',
});
expect(result.env.ANTHROPIC_BASE_URL).toBe('https://api.moonshot.cn/anthropic');
});
it('omits ANTHROPIC_BASE_URL when baseURL is missing or blank', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'key', baseURL: ' ' },
model: 'claude-sonnet-4-5',
sdkType: 'anthropic',
});
expect(result.env.ANTHROPIC_BASE_URL).toBeUndefined();
});
it('adds ANTHROPIC_SMALL_FAST_MODEL only when provided', () => {
const withFast = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'key' },
model: 'claude-sonnet-4-5',
sdkType: 'anthropic',
smallFastModel: 'claude-haiku-4-5',
});
expect(withFast.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('claude-haiku-4-5');
const without = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'key' },
model: 'claude-sonnet-4-5',
sdkType: 'anthropic',
});
expect(without.env.ANTHROPIC_SMALL_FAST_MODEL).toBeUndefined();
});
it('returns an error when apiKey is missing', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: {},
model: 'claude-sonnet-4-5',
sdkType: 'anthropic',
});
expect(result.env).toEqual({});
expect(result.error).toMatch(/apiKey/);
});
it('trims whitespace from credentials', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: { apiKey: ' sk-ant-test ', baseURL: ' https://x.com ' },
model: 'claude-sonnet-4-5',
sdkType: 'anthropic',
});
expect(result.env.ANTHROPIC_API_KEY).toBe('sk-ant-test');
expect(result.env.ANTHROPIC_BASE_URL).toBe('https://x.com');
});
});
it('errors when model id is empty', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'key' },
model: '',
sdkType: 'anthropic',
});
expect(result.error).toMatch(/Model/);
});
it('errors for unsupported sdkType', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'key' },
model: 'gpt-4',
sdkType: 'openai',
});
expect(result.env).toEqual({});
expect(result.error).toMatch(/sdkType/);
});
it('errors when sdkType is undefined', () => {
const result = buildClaudeCodeApiEnv({
keyVaults: { apiKey: 'key' },
model: 'x',
});
expect(result.error).toMatch(/sdkType/);
});
});
@@ -0,0 +1,72 @@
import type { AiProviderSDKType } from '@lobechat/types';
export interface BuildClaudeCodeApiEnvInput {
/** Decrypted keyVaults for the provider */
keyVaults?: Record<string, unknown>;
/** Primary model id → ANTHROPIC_MODEL (or equivalent) */
model: string;
/** Provider sdkType (e.g. 'anthropic', 'bedrock') */
sdkType?: AiProviderSDKType | string;
/** Optional fast-path model → ANTHROPIC_SMALL_FAST_MODEL */
smallFastModel?: string;
}
export interface BuildClaudeCodeApiEnvResult {
env: Record<string, string>;
/** Non-null when inputs are insufficient (missing key / unsupported sdk). */
error?: string;
}
const pickString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const buildAnthropicEnv = (input: BuildClaudeCodeApiEnvInput): BuildClaudeCodeApiEnvResult => {
const apiKey = pickString(input.keyVaults?.apiKey);
if (!apiKey) {
return { env: {}, error: 'Provider apiKey is missing. Configure it in provider settings.' };
}
const env: Record<string, string> = {
ANTHROPIC_API_KEY: apiKey,
ANTHROPIC_MODEL: input.model,
};
const baseURL = pickString(input.keyVaults?.baseURL);
if (baseURL) env.ANTHROPIC_BASE_URL = baseURL;
const smallFastModel = pickString(input.smallFastModel);
if (smallFastModel) env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
return { env };
};
/**
* Translate a LobeHub AI-provider binding into env vars Claude Code CLI understands.
*
* CC reads credentials from env vars (ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL / ANTHROPIC_MODEL
* / ANTHROPIC_SMALL_FAST_MODEL) that take precedence over ~/.claude/credentials.json,
* so injecting them at spawn time is enough to override the subscription session without
* touching the user's stored login.
*
* Phase 1 supports sdkType === 'anthropic' (covers Anthropic official, Moonshot, Kimi
* CodingPlan, and any custom provider with an Anthropic-compatible `/v1/messages` endpoint).
*/
export const buildClaudeCodeApiEnv = (
input: BuildClaudeCodeApiEnvInput,
): BuildClaudeCodeApiEnvResult => {
if (!input.model) {
return { env: {}, error: 'Model id is required for API mode.' };
}
switch (input.sdkType) {
case 'anthropic': {
return buildAnthropicEnv(input);
}
default: {
return {
env: {},
error: `Claude Code API mode does not support sdkType="${input.sdkType ?? 'unknown'}" yet.`,
};
}
}
};
@@ -1,4 +1,9 @@
export { ClaudeCodeAdapter, claudeCodePreset } from './adapters';
export {
buildClaudeCodeApiEnv,
type BuildClaudeCodeApiEnvInput,
type BuildClaudeCodeApiEnvResult,
} from './claudeCodeEnv';
export { getHeterogeneousAgentConfig, HETEROGENEOUS_AGENT_CONFIGS } from './config';
export { HETEROGENEOUS_TYPE_LABELS } from './labels';
export { createAdapter, getPreset, listAgentTypes } from './registry';
+24
View File
@@ -1,11 +1,35 @@
/**
* Authentication mode for a heterogeneous agent CLI.
* - 'subscription': use the CLI's built-in auth (e.g. `claude auth login`).
* - 'api': inject API credentials from a LobeHub provider at spawn time.
*/
export type HeterogeneousAuthMode = 'subscription' | 'api';
/**
* API-mode config: bind a LobeHub provider + model to the CLI.
* Resolved into env vars (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, etc.) when spawning.
*/
export interface HeterogeneousApiConfig {
/** Primary model id, maps to ANTHROPIC_MODEL / equivalent */
model: string;
/** LobeHub AiProvider.id whose keyVaults supplies credentials */
providerId: string;
/** Optional fast-path model, maps to ANTHROPIC_SMALL_FAST_MODEL */
smallFastModel?: string;
}
/**
* Heterogeneous agent provider configuration.
* When set, the assistant delegates execution to an external agent CLI
* instead of using the built-in model runtime.
*/
export interface HeterogeneousProviderConfig {
/** API-mode binding to a LobeHub provider. Only read when authMode === 'api'. */
apiConfig?: HeterogeneousApiConfig;
/** Additional CLI arguments for the agent command */
args?: string[];
/** Auth mode. Defaults to 'subscription' for backwards compatibility. */
authMode?: HeterogeneousAuthMode;
/** Command to spawn the agent (e.g. 'claude') */
command?: string;
/** Custom environment variables */
@@ -0,0 +1,67 @@
import isEqual from 'fast-deep-equal';
import { useMemo } from 'react';
import { useAiInfraStore } from '@/store/aiInfra';
/** Provider sdkTypes whose API can be driven by Claude Code's env-var knobs. */
const CC_COMPATIBLE_SDK_TYPES = new Set(['anthropic']);
export interface CCCompatibleProvider {
id: string;
logo?: string;
name?: string;
}
export interface CCCompatibleModel {
abilities?: Record<string, unknown>;
displayName?: string;
id: string;
providerId: string;
}
export interface CCCompatibleProvidersResult {
modelsByProvider: Record<string, CCCompatibleModel[]>;
providers: CCCompatibleProvider[];
}
/**
* Enumerate LobeHub providers whose `sdkType` can be wired into Claude Code
* via `ANTHROPIC_*` env vars, plus the chat models each exposes. Used by the
* API-mode picker on the agent profile and the inline model toggle in chat.
*/
export const useClaudeCodeCompatibleProviders = (): CCCompatibleProvidersResult => {
// `enabledAiProviders` is hydrated by `initAiProviderRuntimeState` (runs on app
// boot / chat layer), unlike `aiProviderList` which is only fetched lazily on the
// provider-settings page. The agent profile must work without that heavier fetch,
// so read the always-available list here.
const providerList = useAiInfraStore((s) => s.enabledAiProviders ?? [], isEqual);
const runtimeConfig = useAiInfraStore((s) => s.aiProviderRuntimeConfig, isEqual);
const enabledModels = useAiInfraStore((s) => s.enabledAiModels, isEqual);
return useMemo(() => {
const providers: CCCompatibleProvider[] = providerList
.filter((p) => {
const sdkType = runtimeConfig[p.id]?.settings?.sdkType;
return sdkType ? CC_COMPATIBLE_SDK_TYPES.has(sdkType) : false;
})
.map((p) => ({ id: p.id, name: p.name }));
const compatibleIds = new Set(providers.map((p) => p.id));
const modelsByProvider: Record<string, CCCompatibleModel[]> = {};
for (const model of enabledModels ?? []) {
if (model.type !== 'chat') continue;
if (!compatibleIds.has(model.providerId)) continue;
if (!modelsByProvider[model.providerId]) modelsByProvider[model.providerId] = [];
modelsByProvider[model.providerId].push({
abilities: model.abilities as Record<string, unknown> | undefined,
displayName: model.displayName,
id: model.id,
providerId: model.providerId,
});
}
return { modelsByProvider, providers };
}, [providerList, runtimeConfig, enabledModels]);
};
+12 -1
View File
@@ -39,7 +39,10 @@ interface ModelSelectProps extends Pick<SelectProps, 'loading' | 'size' | 'style
defaultValue?: { model: string; provider?: string };
initialWidth?: boolean;
onChange?: (props: { model: string; provider: string }) => void;
placeholder?: string;
popupWidth?: number;
/** Restrict the picker to these provider ids. Empty array → show everything. */
providerIds?: string[];
requiredAbilities?: (keyof EnabledProviderWithModels['children'][number]['abilities'])[];
showAbility?: boolean;
value?: { model: string; provider?: string };
@@ -57,8 +60,15 @@ const ModelSelect = memo<ModelSelectProps>(
variant,
initialWidth = false,
popupWidth,
placeholder,
providerIds,
}) => {
const enabledList = useEnabledChatModels();
const fullList = useEnabledChatModels();
const enabledList = useMemo(() => {
if (!providerIds || providerIds.length === 0) return fullList;
const allow = new Set(providerIds);
return fullList.filter((p) => allow.has(p.id));
}, [fullList, providerIds]);
const options = useMemo<SelectProps['options']>(() => {
const getChatModels = (provider: EnabledProviderWithModels) => {
@@ -110,6 +120,7 @@ const ModelSelect = memo<ModelSelectProps>(
defaultValue={`${value?.provider}/${value?.model}`}
loading={loading}
options={options}
placeholder={placeholder}
popupClassName={styles.popup}
popupMatchSelectWidth={popupWidth === undefined ? false : popupWidth}
size={size}
+2
View File
@@ -155,6 +155,8 @@ export default {
'groupWizard.searchTemplates': 'Search templates...',
'groupWizard.title': 'Create Group',
'groupWizard.useTemplate': 'Use Template',
'heteroAgent.apiMode.providerNotFound':
'The provider "{{providerId}}" is not configured. Open provider settings to set an API key first.',
'heteroAgent.fullAccess.label': 'Full access',
'heteroAgent.fullAccess.tooltip':
'Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.',
+10 -1
View File
@@ -206,7 +206,16 @@ export default {
// Heterogeneous agent CLI status (shown on agent profile page in integration mode)
'heterogeneousStatus.account.label': 'Account',
'heterogeneousStatus.auth.api': 'API',
'heterogeneousStatus.apiMode.configureProvider': 'Go to provider settings',
'heterogeneousStatus.apiMode.model': 'Model',
'heterogeneousStatus.apiMode.modelPlaceholder': 'Select a model',
'heterogeneousStatus.apiMode.noProviders':
'No provider with an Anthropic-compatible endpoint is configured.',
'heterogeneousStatus.apiMode.provider': 'Provider',
'heterogeneousStatus.apiMode.providerPlaceholder': 'Select a provider',
'heterogeneousStatus.apiMode.smallFastModel': 'Fast Model',
'heterogeneousStatus.apiMode.smallFastModelPlaceholder': 'Optional — for background tasks',
'heterogeneousStatus.auth.api': 'Custom API',
'heterogeneousStatus.auth.label': 'Auth Method',
'heterogeneousStatus.auth.subscription': 'Subscription',
'heterogeneousStatus.command.edit': 'Edit command',
@@ -0,0 +1,66 @@
'use client';
import type { HeterogeneousApiConfig } from '@lobechat/types';
import { memo, useMemo } from 'react';
import { useClaudeCodeCompatibleProviders } from '@/features/Electron/HeterogeneousAgent/hooks/useClaudeCodeCompatibleProviders';
import ModelSelect from '@/features/ModelSelect';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
interface ApiModeModelBarProps {
agentId: string;
}
const ApiModeModelBar = memo<ApiModeModelBarProps>(({ agentId }) => {
const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId));
const updateAgentConfigById = useAgentStore((s) => s.updateAgentConfigById);
const { providers } = useClaudeCodeCompatibleProviders();
const providerIds = useMemo(() => providers.map((p) => p.id), [providers]);
const heterogeneousProvider = agencyConfig?.heterogeneousProvider;
const authMode = heterogeneousProvider?.authMode ?? 'subscription';
const apiConfig = heterogeneousProvider?.apiConfig;
// Only render in API mode. Returning null (instead of a placeholder) keeps
// the subscription path visually unchanged.
if (authMode !== 'api' || !heterogeneousProvider) return null;
const persist = async (next: HeterogeneousApiConfig) => {
await updateAgentConfigById(agentId, {
agencyConfig: {
...agencyConfig,
heterogeneousProvider: {
...heterogeneousProvider,
apiConfig: next,
},
},
});
};
const handleChange = async ({ model, provider }: { model: string; provider: string }) => {
// Switching provider drops the previous fast model (different API key). Use ''
// not undefined so config persistence (deep-merge, skips undefined) overwrites it.
const smallFastModel = apiConfig?.providerId === provider ? apiConfig.smallFastModel : '';
await persist({ model, providerId: provider, smallFastModel });
};
return (
<ModelSelect
initialWidth
popupWidth={360}
providerIds={providerIds}
size="small"
value={apiConfig ? { model: apiConfig.model, provider: apiConfig.providerId } : undefined}
variant="borderless"
onChange={(next) => {
void handleChange(next);
}}
/>
);
});
ApiModeModelBar.displayName = 'HeterogeneousApiModeModelBar';
export default ApiModeModelBar;
@@ -24,6 +24,8 @@ import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import ApiModeModelBar from './ApiModeModelBar';
const styles = createStaticStyles(({ css }) => ({
bar: css`
padding-block: 0;
@@ -152,6 +154,7 @@ const WorkingDirectoryBar = memo(() => {
{effectiveWorkingDirectory && repoType && (
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
)}
<ApiModeModelBar agentId={agentId} />
</Flexbox>
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
</Flexbox>
@@ -6,10 +6,13 @@ import { describe, expect, it, vi } from 'vitest';
import HeterogeneousAgentStatusCard from './HeterogeneousAgentStatusCard';
const { detectHeterogeneousAgentCommand, getClaudeAuthStatus } = vi.hoisted(() => ({
detectHeterogeneousAgentCommand: vi.fn(),
getClaudeAuthStatus: vi.fn(),
}));
const { detectHeterogeneousAgentCommand, getClaudeAuthStatus, useCompatibleProviders } = vi.hoisted(
() => ({
detectHeterogeneousAgentCommand: vi.fn(),
getClaudeAuthStatus: vi.fn(),
useCompatibleProviders: vi.fn(() => ({ modelsByProvider: {}, providers: [] })),
}),
);
vi.mock('@lobechat/const', () => ({
isDesktop: true,
@@ -75,11 +78,75 @@ vi.mock('@lobehub/ui', () => ({
}}
/>
),
Segmented: ({
onChange,
options,
value,
}: {
onChange?: (v: string) => void;
options: Array<{ label: ReactNode; value: string }>;
value?: string;
}) => (
<div role="radiogroup">
{options.map((opt) => (
<button
aria-pressed={value === opt.value}
key={opt.value}
type="button"
onClick={() => onChange?.(opt.value)}
>
{opt.label}
</button>
))}
</div>
),
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
Text: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
vi.mock('antd', () => ({
Select: ({
onChange,
options,
value,
}: {
onChange?: (v: string) => void;
options?: Array<{ label: ReactNode; value: string }>;
value?: string;
}) => (
<select value={value ?? ''} onChange={(e) => onChange?.(e.target.value)}>
{(options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{typeof opt.label === 'string' ? opt.label : opt.value}
</option>
))}
</select>
),
}));
vi.mock('@/features/Electron/HeterogeneousAgent/hooks/useClaudeCodeCompatibleProviders', () => ({
useClaudeCodeCompatibleProviders: useCompatibleProviders,
}));
vi.mock('@/features/ModelSelect', () => ({
default: ({
onChange,
value,
}: {
onChange?: (next: { model: string; provider: string }) => void;
value?: { model: string; provider?: string };
}) => (
<button
data-testid="mock-model-select"
type="button"
onClick={() => onChange?.({ model: 'x', provider: 'y' })}
>
{value?.model ?? ''}
</button>
),
}));
vi.mock('antd-style', () => ({
createStyles: () => () => ({
styles: {
@@ -283,6 +350,59 @@ describe('HeterogeneousAgentStatusCard', () => {
});
});
it('clears smallFastModel (to "") when the primary model switches provider', async () => {
// Regression: config persistence deep-merges and skips `undefined` keys, so a
// stale fast model from the previous provider would survive a provider switch
// and get injected as a mismatched ANTHROPIC_SMALL_FAST_MODEL. The handler must
// emit '' so the merge overwrites it.
detectHeterogeneousAgentCommand.mockResolvedValue({ available: true });
getClaudeAuthStatus.mockResolvedValue(null);
useCompatibleProviders.mockReturnValue({
modelsByProvider: {
anthropic: [{ id: 'claude-haiku-4-5', providerId: 'anthropic' }],
kimicodingplan: [{ id: 'kimi-for-coding', providerId: 'kimicodingplan' }],
},
providers: [
{ id: 'anthropic', name: 'Anthropic' },
{ id: 'kimicodingplan', name: 'Kimi Code' },
],
});
const onApiConfigChange = vi.fn();
const provider = {
apiConfig: {
model: 'kimi-k2.5',
providerId: 'kimicodingplan',
smallFastModel: 'kimi-for-coding',
},
authMode: 'api',
command: 'claude',
type: 'claude-code',
} satisfies HeterogeneousProviderConfig;
render(
<MemoryRouter>
<HeterogeneousAgentStatusCard provider={provider} onApiConfigChange={onApiConfigChange} />
</MemoryRouter>,
);
// The first ModelSelect is the primary-model picker; its mock fires
// onChange({ model: 'x', provider: 'y' }) — a different provider than the
// bound 'kimicodingplan', so the fast model must be dropped.
const modelSelects = await screen.findAllByTestId('mock-model-select');
fireEvent.click(modelSelects[0]);
await waitFor(() => {
expect(onApiConfigChange).toHaveBeenCalledWith({
model: 'x',
providerId: 'y',
smallFastModel: '',
});
});
useCompatibleProviders.mockReturnValue({ modelsByProvider: {}, providers: [] });
});
it('keeps the command read-only until edit mode is activated', async () => {
detectHeterogeneousAgentCommand.mockResolvedValue({ available: true });
getClaudeAuthStatus.mockResolvedValue(null);
@@ -3,15 +3,27 @@
import { isDesktop } from '@lobechat/const';
import { type ClaudeAuthStatus, type ToolStatus } from '@lobechat/electron-client-ipc';
import { getHeterogeneousAgentClientConfig } from '@lobechat/heterogeneous-agents/client';
import type { HeterogeneousProviderConfig } from '@lobechat/types';
import { ActionIcon, CopyButton, Flexbox, Icon, Input, Tag, Text, Tooltip } from '@lobehub/ui';
import type { HeterogeneousApiConfig, HeterogeneousProviderConfig } from '@lobechat/types';
import {
ActionIcon,
CopyButton,
Flexbox,
Icon,
Input,
Segmented,
Tag,
Text,
Tooltip,
} from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Loader2Icon, PencilLine, RefreshCw, XCircle } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useClaudeCodeCompatibleProviders } from '@/features/Electron/HeterogeneousAgent/hooks/useClaudeCodeCompatibleProviders';
import HeterogeneousAgentStatusGuide from '@/features/Electron/HeterogeneousAgent/StatusGuide';
import ModelSelect from '@/features/ModelSelect';
import { toolDetectorService } from '@/services/electron/toolDetector';
const COMMAND_LINE_HEIGHT = 28;
@@ -205,12 +217,14 @@ const useStyles = createStyles(({ css, token }) => ({
}));
interface HeterogeneousAgentStatusCardProps {
onApiConfigChange?: (apiConfig: HeterogeneousApiConfig | undefined) => Promise<void> | void;
onAuthModeChange?: (authMode: 'subscription' | 'api') => Promise<void> | void;
onCommandChange?: (command: string) => Promise<void> | void;
provider: HeterogeneousProviderConfig;
}
const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
({ provider, onCommandChange }) => {
({ provider, onCommandChange, onAuthModeChange, onApiConfigChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const navigate = useNavigate();
@@ -234,20 +248,57 @@ const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
!status?.available &&
!isUsingCustomCommand;
const fetchAuth = useCallback(async () => {
if (provider.type !== 'claude-code') {
setAuth(null);
return;
}
const authMode = provider.authMode ?? 'subscription';
const apiConfig = provider.apiConfig;
const supportsApiMode = provider.type === 'claude-code';
const { providers: ccProviders, modelsByProvider } = useClaudeCodeCompatibleProviders();
const ccProviderIds = useMemo(() => ccProviders.map((p) => p.id), [ccProviders]);
try {
const result = await toolDetectorService.getClaudeAuthStatus(resolvedCommand);
setAuth(result);
} catch (error) {
console.warn('[HeterogeneousAgentStatusCard] Failed to get Claude auth status:', error);
setAuth(null);
}
}, [provider.type, resolvedCommand]);
const handleAuthModeChange = useCallback(
async (next: 'subscription' | 'api') => {
if (next === authMode) return;
await onAuthModeChange?.(next);
// On first switch to API mode, auto-pick the first compatible provider/model
// so the user sees a working default instead of a blank form.
if (next === 'api' && !apiConfig && ccProviders.length > 0) {
const firstProvider = ccProviders[0];
const firstModel = modelsByProvider[firstProvider.id]?.[0];
if (firstModel) {
await onApiConfigChange?.({
model: firstModel.id,
providerId: firstProvider.id,
});
}
}
},
[authMode, apiConfig, ccProviders, modelsByProvider, onApiConfigChange, onAuthModeChange],
);
const handleModelSelect = useCallback(
async ({ model, provider: nextProviderId }: { model: string; provider: string }) => {
// If the user switched to a different provider, the previous smallFastModel
// no longer belongs to the new API key — drop it. Use '' rather than
// undefined: config persistence deep-merges and skips undefined keys, so a
// stale fast model would survive. An empty string overwrites it and is
// treated as "unset" by the spawn-time env builder.
const smallFastModel =
apiConfig?.providerId === nextProviderId ? apiConfig.smallFastModel : '';
await onApiConfigChange?.({
model,
providerId: nextProviderId,
smallFastModel,
});
},
[apiConfig, onApiConfigChange],
);
const handleSmallFastModelSelect = useCallback(
async ({ model }: { model: string; provider: string }) => {
if (!apiConfig) return;
await onApiConfigChange?.({ ...apiConfig, smallFastModel: model || undefined });
},
[apiConfig, onApiConfigChange],
);
const detect = useCallback(async () => {
if (!isDesktop || !resolvedCommand) {
@@ -262,24 +313,48 @@ const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
command: resolvedCommand,
});
setStatus(result);
if (result.available) {
void fetchAuth();
} else {
setAuth(null);
}
} catch (error) {
console.error('[HeterogeneousAgentStatusCard] Failed to detect CLI:', error);
setStatus({ available: false, error: (error as Error).message });
setAuth(null);
} finally {
setDetecting(false);
}
}, [fetchAuth, provider.type, resolvedCommand]);
}, [provider.type, resolvedCommand]);
useEffect(() => {
void detect();
}, [detect]);
// Fetch subscription auth status as a SEPARATE effect so toggling authMode
// doesn't rebuild `detect` and flash the top "可用" status row back to the
// "detecting…" placeholder.
useEffect(() => {
if (
provider.type !== 'claude-code' ||
authMode === 'api' ||
!status?.available ||
!resolvedCommand
) {
setAuth(null);
return;
}
let cancelled = false;
(async () => {
try {
const result = await toolDetectorService.getClaudeAuthStatus(resolvedCommand);
if (!cancelled) setAuth(result);
} catch (error) {
console.warn('[HeterogeneousAgentStatusCard] Failed to get Claude auth status:', error);
if (!cancelled) setAuth(null);
}
})();
return () => {
cancelled = true;
};
}, [authMode, provider.type, resolvedCommand, status?.available]);
useEffect(() => {
setCommandInput(resolvedCommand);
}, [resolvedCommand]);
@@ -453,23 +528,47 @@ const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
);
};
const renderAuth = () => {
if (provider.type !== 'claude-code' || detecting || !status?.available || !auth?.loggedIn)
return null;
const renderAuthModeRow = () => {
if (!supportsApiMode || detecting || !status?.available) return null;
const authMode =
auth.authMethod === 'claude.ai' || auth.apiProvider === 'firstParty'
? t('heterogeneousStatus.auth.subscription')
: t('heterogeneousStatus.auth.api');
return (
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>{t('heterogeneousStatus.auth.label')}</Text>
<Flexbox horizontal align="center" gap={8} style={{ flexWrap: 'wrap' }}>
<Segmented
size="small"
value={authMode}
options={[
{
label: t('heterogeneousStatus.auth.subscription'),
value: 'subscription',
},
{
label: t('heterogeneousStatus.auth.api'),
value: 'api',
},
]}
onChange={(next) => {
void handleAuthModeChange(next as 'subscription' | 'api');
}}
/>
</Flexbox>
</div>
);
};
const renderSubscriptionAccount = () => {
if (
!supportsApiMode ||
authMode !== 'subscription' ||
detecting ||
!status?.available ||
!auth?.loggedIn
)
return null;
return (
<>
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>{t('heterogeneousStatus.auth.label')}</Text>
<Flexbox horizontal align="center" gap={8} style={{ flexWrap: 'wrap' }}>
<Text className={styles.accountValue}>{authMode}</Text>
</Flexbox>
</div>
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>{t('heterogeneousStatus.account.label')}</Text>
<Flexbox horizontal align="center" gap={8} style={{ flexWrap: 'wrap' }}>
@@ -492,6 +591,77 @@ const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
);
};
const renderApiConfig = () => {
if (!supportsApiMode || authMode !== 'api' || detecting || !status?.available) return null;
if (ccProviders.length === 0) {
return (
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>{t('heterogeneousStatus.apiMode.model')}</Text>
<Flexbox horizontal align="center" gap={8} style={{ flexWrap: 'wrap' }}>
<Text className={styles.unavailableText}>
{t('heterogeneousStatus.apiMode.noProviders')}
</Text>
<Text
className={styles.metaText}
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => navigate('/settings/provider')}
>
{t('heterogeneousStatus.apiMode.configureProvider')}
</Text>
</Flexbox>
</div>
);
}
// Fast model must share the primary model's provider (one API key per run),
// so if the provider hasn't been chosen yet we restrict to nothing.
const fastModelProviderIds = apiConfig ? [apiConfig.providerId] : [];
return (
<>
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>{t('heterogeneousStatus.apiMode.model')}</Text>
<Flexbox horizontal align="center" gap={8} style={{ flexWrap: 'wrap' }}>
<ModelSelect
initialWidth
placeholder={t('heterogeneousStatus.apiMode.modelPlaceholder')}
popupWidth={360}
providerIds={ccProviderIds}
value={
apiConfig ? { model: apiConfig.model, provider: apiConfig.providerId } : undefined
}
onChange={(next) => {
void handleModelSelect(next);
}}
/>
</Flexbox>
</div>
<div className={styles.detailRow}>
<Text className={styles.detailLabel}>
{t('heterogeneousStatus.apiMode.smallFastModel')}
</Text>
<Flexbox horizontal align="center" gap={8} style={{ flexWrap: 'wrap' }}>
<ModelSelect
initialWidth
placeholder={t('heterogeneousStatus.apiMode.smallFastModelPlaceholder')}
popupWidth={360}
providerIds={fastModelProviderIds}
value={
apiConfig?.smallFastModel
? { model: apiConfig.smallFastModel, provider: apiConfig.providerId }
: undefined
}
onChange={(next) => {
void handleSmallFastModelSelect(next);
}}
/>
</Flexbox>
</div>
</>
);
};
return (
<Flexbox className={styles.card} gap={12}>
<div className={styles.cardHeader}>
@@ -518,7 +688,9 @@ const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
</div>
<div className={styles.detailList}>
{renderCommandEditor()}
{renderAuth()}
{renderAuthModeRow()}
{renderSubscriptionAccount()}
{renderApiConfig()}
</div>
{showCliInstallGuide && (
<HeterogeneousAgentStatusGuide
@@ -1,6 +1,7 @@
'use client';
import { isDesktop } from '@lobechat/const';
import type { HeterogeneousProviderConfig } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { Divider, Tabs } from 'antd';
import isEqual from 'fast-deep-equal';
@@ -43,6 +44,34 @@ const ProfileEditor = memo(() => {
});
};
const updateHeterogeneousAuthMode = async (authMode: 'subscription' | 'api') => {
if (!heterogeneousProvider) return;
await updateConfig({
agencyConfig: {
heterogeneousProvider: {
...heterogeneousProvider,
authMode,
},
},
});
};
const updateHeterogeneousApiConfig = async (
apiConfig: HeterogeneousProviderConfig['apiConfig'],
) => {
if (!heterogeneousProvider) return;
await updateConfig({
agencyConfig: {
heterogeneousProvider: {
...heterogeneousProvider,
apiConfig,
},
},
});
};
return (
<>
<Flexbox
@@ -76,6 +105,8 @@ const ProfileEditor = memo(() => {
children: (
<HeterogeneousAgentStatusCard
provider={heterogeneousProvider}
onApiConfigChange={updateHeterogeneousApiConfig}
onAuthModeChange={updateHeterogeneousAuthMode}
onCommandChange={updateHeterogeneousCommand}
/>
),
@@ -11,6 +11,7 @@ import {
HeterogeneousAgentSessionErrorCode,
} from '@lobechat/electron-client-ipc';
import type { SubagentEventContext, ToolCallPayload } from '@lobechat/heterogeneous-agents';
import { buildClaudeCodeApiEnv } from '@lobechat/heterogeneous-agents';
import type {
ChatMessageError,
ChatToolPayload,
@@ -28,6 +29,7 @@ import { message as antdMessage } from '@/components/AntdStaticMethods';
import { heterogeneousAgentService } from '@/services/electron/heterogeneousAgent';
import { messageService } from '@/services/message';
import { threadService } from '@/services/thread';
import { aiProviderSelectors, getAiInfraStoreState } from '@/store/aiInfra';
import { type ChatStore, useChatStore } from '@/store/chat/store';
import { resolveNotificationNavigatePath } from '@/store/chat/utils/desktopNotification';
import { markdownToTxt } from '@/utils/markdownToTxt';
@@ -1305,6 +1307,46 @@ export const executeHeterogeneousAgent = async (
return undefined;
};
// When authMode === 'api', translate the bound LobeHub provider into
// env vars the CLI understands (ANTHROPIC_API_KEY / _BASE_URL / _MODEL …).
// These are merged on top of user-provided `env` so the provider binding wins.
let resolvedEnv = heterogeneousProvider.env;
if (
adapterType === 'claude-code' &&
heterogeneousProvider.authMode === 'api' &&
heterogeneousProvider.apiConfig
) {
const { apiConfig } = heterogeneousProvider;
const providerConfig = aiProviderSelectors.providerConfigById(apiConfig.providerId)(
getAiInfraStoreState(),
);
if (!providerConfig) {
const errMsg = t('heteroAgent.apiMode.providerNotFound', {
ns: 'chat',
providerId: apiConfig.providerId,
});
await persistTerminalError(toHeterogeneousAgentMessageError(new Error(errMsg), adapterType));
return;
}
const { env: apiEnv, error: apiEnvError } = buildClaudeCodeApiEnv({
keyVaults: providerConfig.keyVaults,
model: apiConfig.model,
sdkType: providerConfig.settings.sdkType,
smallFastModel: apiConfig.smallFastModel,
});
if (apiEnvError) {
await persistTerminalError(
toHeterogeneousAgentMessageError(new Error(apiEnvError), adapterType),
);
return;
}
resolvedEnv = { ...heterogeneousProvider.env, ...apiEnv };
}
try {
// Start session (pass resumeSessionId for multi-turn --resume)
const result = await heterogeneousAgentService.startSession({
@@ -1312,7 +1354,7 @@ export const executeHeterogeneousAgent = async (
args: heterogeneousProvider.args,
command: heterogeneousProvider.command || (adapterType === 'codex' ? 'codex' : 'claude'),
cwd: workingDirectory,
env: heterogeneousProvider.env,
env: resolvedEnv,
resumeSessionId,
});
agentSessionId = result.sessionId;