mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 21:08:36 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d66af8156 |
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "需要配置云端凭证",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+66
@@ -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;
|
||||
+3
@@ -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>
|
||||
|
||||
+124
-4
@@ -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);
|
||||
|
||||
+210
-38
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user