From 8018ac1a97fb6562b3a24df3f6515d01c96f4447 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 27 Sep 2025 10:06:09 +0800 Subject: [PATCH 1/4] feat(agent): add optional avatar and slash_commands to AgentConfigurationSchema --- src/renderer/src/types/agent.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/types/agent.ts b/src/renderer/src/types/agent.ts index aa491b396d..eecce7dc33 100644 --- a/src/renderer/src/types/agent.ts +++ b/src/renderer/src/types/agent.ts @@ -45,6 +45,9 @@ export type Tool = z.infer // ------------------ Agent configuration & base schema ------------------ export const AgentConfigurationSchema = z .object({ + avatar: z.string().optional(), // URL or path to avatar image + slash_commands: z.array(z.string()).optional(), // Array of slash commands to trigger the agent + // https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors permission_mode: PermissionModeSchema.default('default'), // Permission mode, default to 'default' max_turns: z.number().default(100) // Maximum number of interaction turns, default to 100 From ae9e12b276b359a34efee37bca1ee2198eeec1eb Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 27 Sep 2025 10:26:48 +0800 Subject: [PATCH 2/4] feat: add slash command functionality to agent services --- src/main/services/agents/BaseService.ts | 10 +++++++- .../agents/services/SessionService.ts | 1 + .../agents/services/claudecode/commands.ts | 25 +++++++++++++++++++ src/renderer/src/types/agent.ts | 12 +++++++-- 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/main/services/agents/services/claudecode/commands.ts diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index 3f0282d3e4..73a1a1828a 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -2,7 +2,7 @@ import { type Client, createClient } from '@libsql/client' import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' import { ModelValidationError, validateModelId } from '@main/apiServer/utils' -import { AgentType, MCPTool, objectKeys, Provider, Tool } from '@types' +import { AgentType, MCPTool, objectKeys, Provider, SlashCommand, Tool } from '@types' import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql' import fs from 'fs' import path from 'path' @@ -11,6 +11,7 @@ import { MigrationService } from './database/MigrationService' import * as schema from './database/schema' import { dbPath } from './drizzle.config' import { AgentModelField, AgentModelValidationError } from './errors' +import { builtinSlashCommands } from './services/claudecode/commands' import { builtinTools } from './services/claudecode/tools' const logger = loggerService.withContext('BaseService') @@ -76,6 +77,13 @@ export abstract class BaseService { return tools } + public async listSlashCommands(agentType: AgentType): Promise { + if (agentType === 'claude-code') { + return builtinSlashCommands + } + return [] + } + private static async performInitialization(): Promise { const maxRetries = 3 let lastError: Error diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index fae7c136fc..5fcb60600d 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -110,6 +110,7 @@ export class SessionService extends BaseService { const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse session.tools = await this.listMcpTools(session.agent_type, session.mcps) + session.slash_commands = await this.listSlashCommands(session.agent_type) return session } diff --git a/src/main/services/agents/services/claudecode/commands.ts b/src/main/services/agents/services/claudecode/commands.ts new file mode 100644 index 0000000000..ce90e0978a --- /dev/null +++ b/src/main/services/agents/services/claudecode/commands.ts @@ -0,0 +1,25 @@ +import { SlashCommand } from '@types' + +export const builtinSlashCommands: SlashCommand[] = [ + { command: '/add-dir', description: 'Add additional working directories' }, + { command: '/agents', description: 'Manage custom AI subagents for specialized tasks' }, + { command: '/bug', description: 'Report bugs (sends conversation to Anthropic)' }, + { command: '/clear', description: 'Clear conversation history' }, + { command: '/compact', description: 'Compact conversation with optional focus instructions' }, + { command: '/config', description: 'View/modify configuration' }, + { command: '/cost', description: 'Show token usage statistics' }, + { command: '/doctor', description: 'Checks the health of your Claude Code installation' }, + { command: '/help', description: 'Get usage help' }, + { command: '/init', description: 'Initialize project with CLAUDE.md guide' }, + { command: '/login', description: 'Switch Anthropic accounts' }, + { command: '/logout', description: 'Sign out from your Anthropic account' }, + { command: '/mcp', description: 'Manage MCP server connections and OAuth authentication' }, + { command: '/memory', description: 'Edit CLAUDE.md memory files' }, + { command: '/model', description: 'Select or change the AI model' }, + { command: '/permissions', description: 'View or update permissions' }, + { command: '/pr_comments', description: 'View pull request comments' }, + { command: '/review', description: 'Request code review' }, + { command: '/status', description: 'View account and system statuses' }, + { command: '/terminal-setup', description: 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)' }, + { command: '/vim', description: 'Enter vim mode for alternating insert and command modes' } +] diff --git a/src/renderer/src/types/agent.ts b/src/renderer/src/types/agent.ts index eecce7dc33..126584f1e4 100644 --- a/src/renderer/src/types/agent.ts +++ b/src/renderer/src/types/agent.ts @@ -42,11 +42,18 @@ export const ToolSchema = z.object({ export type Tool = z.infer +export const SlashCommandSchema = z.object({ + command: z.string(), // e.g. '/status' + description: z.string().optional() // e.g. 'Show help information' +}) + +export type SlashCommand = z.infer + // ------------------ Agent configuration & base schema ------------------ export const AgentConfigurationSchema = z .object({ avatar: z.string().optional(), // URL or path to avatar image - slash_commands: z.array(z.string()).optional(), // Array of slash commands to trigger the agent + slash_commands: z.array(z.string()).optional(), // Array of slash commands to trigger the agent, this is from agent init response // https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors permission_mode: PermissionModeSchema.default('default'), // Permission mode, default to 'default' @@ -255,7 +262,8 @@ export interface UpdateSessionRequest extends Partial {} export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({ tools: z.array(ToolSchema).optional(), // All tools available to the session (including built-in and custom) - messages: z.array(AgentSessionMessageEntitySchema).optional() // Messages in the session + messages: z.array(AgentSessionMessageEntitySchema).optional(), // Messages in the session + slash_commands: z.array(SlashCommandSchema).optional() // Array of slash commands to trigger the agent }) export type GetAgentSessionResponse = z.infer From 35b885798b6156975db3ddd28e5fbde0c4e4afc4 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 27 Sep 2025 13:04:19 +0800 Subject: [PATCH 3/4] Add Anthropic API Host support for compatible providers - Add `anthropicApiHost` field to Provider type - Update provider config and migration to set Anthropic endpoints - Add UI for configuring Anthropic API Host in provider settings - Update SDK client logic to use Anthropic API Host when available - Add i18n strings for Anthropic API Host configuration --- packages/shared/anthropic/index.ts | 7 +- .../src/aiCore/provider/providerConfig.ts | 30 ++++++- src/renderer/src/config/providers.ts | 6 ++ src/renderer/src/i18n/locales/en-us.json | 6 ++ src/renderer/src/i18n/locales/zh-cn.json | 6 ++ src/renderer/src/i18n/locales/zh-tw.json | 6 ++ .../ProviderSettings/ProviderSetting.tsx | 84 ++++++++++++++++++- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 69 +++++++++++++-- src/renderer/src/types/index.ts | 2 + 10 files changed, 204 insertions(+), 14 deletions(-) diff --git a/packages/shared/anthropic/index.ts b/packages/shared/anthropic/index.ts index 4cbc78a8c6..251e634edf 100644 --- a/packages/shared/anthropic/index.ts +++ b/packages/shared/anthropic/index.ts @@ -70,10 +70,15 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An } }) } + const baseURL = + provider.type === 'anthropic' + ? provider.apiHost + : (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost + return new Anthropic({ apiKey: provider.apiKey, authToken: provider.apiKey, - baseURL: provider.apiHost, + baseURL, dangerouslyAllowBrowser: true, defaultHeaders: { 'anthropic-beta': 'output-128k-2025-02-19', diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index b91dad9cf7..ea97e0611f 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -79,9 +79,37 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider { /** * 格式化provider的API Host */ +function formatAnthropicApiHost(host: string): string { + const trimmedHost = host?.trim() + + if (!trimmedHost) { + return '' + } + + if (trimmedHost.endsWith('/')) { + return trimmedHost + } + + if (trimmedHost.endsWith('/v1')) { + return `${trimmedHost}/` + } + + return formatApiHost(trimmedHost) +} + function formatProviderApiHost(provider: Provider): Provider { const formatted = { ...provider } - if (formatted.type === 'gemini') { + if (formatted.anthropicApiHost) { + formatted.anthropicApiHost = formatAnthropicApiHost(formatted.anthropicApiHost) + } + + if (formatted.type === 'anthropic') { + const baseHost = formatted.anthropicApiHost || formatted.apiHost + formatted.apiHost = formatAnthropicApiHost(baseHost) + if (!formatted.anthropicApiHost) { + formatted.anthropicApiHost = formatted.apiHost + } + } else if (formatted.type === 'gemini') { formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta') } else { formatted.apiHost = formatApiHost(formatted.apiHost) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 543422d212..b0692e7f17 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -104,6 +104,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://aihubmix.com', + anthropicApiHost: 'https://aihubmix.com/anthropic', models: SYSTEM_MODELS.aihubmix, isSystem: true, enabled: false @@ -124,6 +125,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://open.bigmodel.cn/api/paas/v4/', + anthropicApiHost: 'https://open.bigmodel.cn/api/anthropic', models: SYSTEM_MODELS.zhipu, isSystem: true, enabled: false @@ -134,6 +136,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://api.deepseek.com', + anthropicApiHost: 'https://api.deepseek.com/anthropic', models: SYSTEM_MODELS.deepseek, isSystem: true, enabled: false @@ -379,6 +382,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://api.moonshot.cn', + anthropicApiHost: 'https://api.moonshot.cn/anthropic', models: SYSTEM_MODELS.moonshot, isSystem: true, enabled: false @@ -399,6 +403,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/', + anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy', models: SYSTEM_MODELS.dashscope, isSystem: true, enabled: false @@ -539,6 +544,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://api-inference.modelscope.cn/v1/', + anthropicApiHost: 'https://api-inference.modelscope.cn', models: SYSTEM_MODELS.modelscope, isSystem: true, enabled: false diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a0c7dc7c67..e21a99c76d 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3995,6 +3995,12 @@ } }, "api_host": "API Host", + "api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.", + "api_host_preview": "Preview: {{url}}", + "anthropic_api_host": "Anthropic API Host", + "anthropic_api_host_tooltip": "Use only when the provider offers a Claude-compatible base URL.", + "anthropic_api_host_preview": "Anthropic preview: {{url}}", + "anthropic_api_host_tip": "Only configure this when your provider exposes an Anthropic-compatible endpoint. Ending with / ignores v1, ending with # forces use of input address.", "api_key": { "label": "API Key", "tip": "Multiple keys separated by commas or spaces" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 84f09d5d0a..2c41e60f12 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3995,6 +3995,12 @@ } }, "api_host": "API 地址", + "api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。", + "api_host_preview": "预览:{{url}}", + "anthropic_api_host": "Anthropic API 地址", + "anthropic_api_host_tooltip": "仅当服务商提供 Claude 兼容的基础地址时填写。", + "anthropic_api_host_preview": "Anthropic 预览:{{url}}", + "anthropic_api_host_tip": "仅在服务商提供兼容 Anthropic 的地址时填写。以 / 结尾会忽略自动追加的 v1,以 # 结尾则强制使用原始地址。", "api_key": { "label": "API 密钥", "tip": "多个密钥使用逗号或空格分隔" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 308da89ba9..ab2ebbacc4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3995,6 +3995,12 @@ } }, "api_host": "API 主機地址", + "api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。", + "api_host_preview": "預覽:{{url}}", + "anthropic_api_host": "Anthropic API 主機地址", + "anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。", + "anthropic_api_host_preview": "Anthropic 預覽:{{url}}", + "anthropic_api_host_tip": "僅在服務商提供與 Anthropic 相容的網址時設定。以 / 結尾會忽略自動附加的 v1,以 # 結尾則強制使用原始地址。", "api_key": { "label": "API 金鑰", "tip": "多個金鑰使用逗號或空格分隔" diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index ea40f6d9ac..bd3163163f 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -55,11 +55,14 @@ interface Props { providerId: string } +const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope', 'aihubmix'] + const ProviderSetting: FC = ({ providerId }) => { const { provider, updateProvider, models } = useProvider(providerId) const allProviders = useAllProviders() const { updateProviders } = useProviders() const [apiHost, setApiHost] = useState(provider.apiHost) + const [anthropicApiHost, setAnthropicHost] = useState(provider.anthropicApiHost) const [apiVersion, setApiVersion] = useState(provider.apiVersion) const { t } = useTranslation() const { theme } = useTheme() @@ -140,6 +143,17 @@ const ProviderSetting: FC = ({ providerId }) => { } } + const onUpdateAnthropicHost = () => { + const trimmedHost = anthropicApiHost?.trim() + + if (trimmedHost) { + updateProvider({ anthropicApiHost: trimmedHost }) + setAnthropicHost(trimmedHost) + } else { + updateProvider({ anthropicApiHost: undefined }) + setAnthropicHost(undefined) + } + } const onUpdateApiVersion = () => updateProvider({ apiVersion }) const openApiKeyList = async () => { @@ -245,6 +259,34 @@ const ProviderSetting: FC = ({ providerId }) => { setApiHost(provider.apiHost) }, [provider.apiHost, provider.id]) + useEffect(() => { + setAnthropicHost(provider.anthropicApiHost) + }, [provider.anthropicApiHost]) + + const canConfigureAnthropicHost = useMemo(() => { + return provider.type !== 'anthropic' && ANTHROPIC_COMPATIBLE_PROVIDER_IDS.includes(provider.id) + }, [provider]) + + const anthropicHostPreview = useMemo(() => { + const rawHost = (anthropicApiHost ?? provider.anthropicApiHost)?.trim() + if (!rawHost) { + return '' + } + + if (/\/messages\/?$/.test(rawHost)) { + return rawHost.replace(/\/$/, '') + } + + let normalizedHost = rawHost + if (/\/v\d+(?:\/)?$/i.test(normalizedHost)) { + normalizedHost = normalizedHost.replace(/\/$/, '') + } else { + normalizedHost = formatApiHost(normalizedHost).replace(/\/$/, '') + } + + return `${normalizedHost}/messages` + }, [anthropicApiHost, provider.anthropicApiHost]) + const isAnthropicOAuth = () => provider.id === 'anthropic' && provider.authType === 'oauth' return ( @@ -351,7 +393,9 @@ const ProviderSetting: FC = ({ providerId }) => { {!isDmxapi && !isAnthropicOAuth() && ( <> - {t('settings.provider.api_host')} + + {t('settings.provider.api_host')} + )} + {(isOpenAIProvider(provider) || isAnthropicProvider(provider)) && ( - {hostPreview()} + {t('settings.provider.api_host_preview', { url: hostPreview() })} {t('settings.provider.api.url.tip')} )} + + {canConfigureAnthropicHost && ( + <> + + + {t('settings.provider.anthropic_api_host')} + + + + setAnthropicHost(e.target.value)} + onBlur={onUpdateAnthropicHost} + /> + + + + {t('settings.provider.anthropic_api_host_preview', { + url: anthropicHostPreview || '—' + })} + + + {t('settings.provider.anthropic_api_host_tip')} + + + + )} )} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index aa8e8342ef..201108a7f3 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -65,7 +65,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 158, + version: 159, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 80ee26d7c4..35dd216565 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -51,9 +51,18 @@ const logger = loggerService.withContext('Migrate') // remove logo base64 data to reduce the size of the state function removeMiniAppIconsFromState(state: RootState) { if (state.minapps) { - state.minapps.enabled = state.minapps.enabled.map((app) => ({ ...app, logo: undefined })) - state.minapps.disabled = state.minapps.disabled.map((app) => ({ ...app, logo: undefined })) - state.minapps.pinned = state.minapps.pinned.map((app) => ({ ...app, logo: undefined })) + state.minapps.enabled = state.minapps.enabled.map((app) => ({ + ...app, + logo: undefined + })) + state.minapps.disabled = state.minapps.disabled.map((app) => ({ + ...app, + logo: undefined + })) + state.minapps.pinned = state.minapps.pinned.map((app) => ({ + ...app, + logo: undefined + })) } } @@ -96,7 +105,10 @@ function updateProvider(state: RootState, id: string, provider: Partial p.id === id) if (index !== -1) { - state.llm.providers[index] = { ...state.llm.providers[index], ...provider } + state.llm.providers[index] = { + ...state.llm.providers[index], + ...provider + } } } } @@ -544,7 +556,10 @@ const migrateConfig = { ...state.llm, providers: state.llm.providers.map((provider) => { if (provider.id === 'azure-openai') { - provider.models = provider.models.map((model) => ({ ...model, provider: 'azure-openai' })) + provider.models = provider.models.map((model) => ({ + ...model, + provider: 'azure-openai' + })) } return provider }) @@ -600,7 +615,10 @@ const migrateConfig = { runAsyncFunction(async () => { const _topic = await db.topics.get(topic.id) if (_topic) { - const messages = (_topic?.messages || []).map((message) => ({ ...message, assistantId: assistant.id })) + const messages = (_topic?.messages || []).map((message) => ({ + ...message, + assistantId: assistant.id + })) db.topics.put({ ..._topic, messages }, topic.id) } }) @@ -1717,7 +1735,10 @@ const migrateConfig = { cutoffLimit: state.websearch.contentLimit } } else { - state.websearch.compressionConfig = { method: 'none', cutoffUnit: 'char' } + state.websearch.compressionConfig = { + method: 'none', + cutoffUnit: 'char' + } } // @ts-ignore eslint-disable-next-line @@ -2502,7 +2523,10 @@ const migrateConfig = { const cherryinProvider = state.llm.providers.find((provider) => provider.id === 'cherryin') if (cherryinProvider) { - updateProvider(state, 'cherryin', { apiHost: 'https://open.cherryin.ai', models: [] }) + updateProvider(state, 'cherryin', { + apiHost: 'https://open.cherryin.ai', + models: [] + }) } if (state.llm.defaultModel?.provider === 'cherryin') { @@ -2571,9 +2595,36 @@ const migrateConfig = { return icon === 'agents' ? 'store' : icon }) } + state.llm.providers.forEach((provider) => { + if (provider.anthropicApiHost) { + return + } + + switch (provider.id) { + case 'deepseek': + provider.anthropicApiHost = 'https://api.deepseek.com/anthropic' + break + case 'moonshot': + provider.anthropicApiHost = 'https://api.moonshot.cn/anthropic' + break + case 'zhipu': + provider.anthropicApiHost = 'https://open.bigmodel.cn/api/anthropic' + break + case 'dashscope': + provider.anthropicApiHost = 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy' + break + case 'modelscope': + provider.anthropicApiHost = 'https://api-inference.modelscope.cn' + break + case 'aihubmix': + provider.anthropicApiHost = 'https://aihubmix.com/anthropic' + break + } + }) + return state } catch (error) { - logger.error('migrate 158 error', error as Error) + logger.error('migrate 159 error', error as Error) return state } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 49a86cee9b..56bc883fe8 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -13,6 +13,7 @@ import type { FileMetadata } from './file' import { KnowledgeBase, KnowledgeReference } from './knowledge' import { MCPConfigSample, McpServerType } from './mcp' import type { Message } from './newMessage' +import type { ServiceTier } from './provider' import type { BaseTool, MCPTool } from './tool' export * from './agent' @@ -251,6 +252,7 @@ export type Provider = { name: string apiKey: string apiHost: string + anthropicApiHost?: string apiVersion?: string models: Model[] enabled?: boolean From 4d133d59ea5ddbe2d2f092e988699b56f33030bf Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 27 Sep 2025 14:06:46 +0800 Subject: [PATCH 4/4] feat: enhance Anthropic API support for compatible providers - Add support for anthropicApiHost configuration in providers - Improve model filtering for Anthropic-compatible providers - Add isAnthropicModel function to validate Anthropic models - Update ClaudeCode service to support compatible providers - Enhance logging and error handling in API routes - Fix model transformation and validation logic --- packages/shared/anthropic/index.ts | 3 ++ src/main/apiServer/routes/messages.ts | 36 ++++++++----------- src/main/apiServer/services/models.ts | 26 +++++++------- src/main/apiServer/utils/index.ts | 23 +++++++----- src/main/services/agents/BaseService.ts | 19 +--------- .../agents/services/claudecode/index.ts | 10 +++++- src/renderer/src/components/ApiModelLabel.tsx | 2 +- src/renderer/src/config/providers.ts | 2 ++ src/renderer/src/store/migrate.ts | 1 + src/renderer/src/types/index.ts | 1 + 10 files changed, 61 insertions(+), 62 deletions(-) diff --git a/packages/shared/anthropic/index.ts b/packages/shared/anthropic/index.ts index 251e634edf..82d85c54fa 100644 --- a/packages/shared/anthropic/index.ts +++ b/packages/shared/anthropic/index.ts @@ -10,7 +10,9 @@ import Anthropic from '@anthropic-ai/sdk' import { TextBlockParam } from '@anthropic-ai/sdk/resources' +import { loggerService } from '@logger' import { Provider } from '@types' +const logger = loggerService.withContext('anthropic-sdk') /** * Creates and configures an Anthropic SDK client based on the provider configuration. @@ -75,6 +77,7 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An ? provider.apiHost : (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost + logger.debug('Anthropic API baseURL', { baseURL }) return new Anthropic({ apiKey: provider.apiKey, authToken: provider.apiKey, diff --git a/src/main/apiServer/routes/messages.ts b/src/main/apiServer/routes/messages.ts index 19d83fb57a..994ee3edd6 100644 --- a/src/main/apiServer/routes/messages.ts +++ b/src/main/apiServer/routes/messages.ts @@ -2,7 +2,8 @@ import { MessageCreateParams } from '@anthropic-ai/sdk/resources' import { loggerService } from '@logger' import express, { Request, Response } from 'express' -import { messagesService } from '../services/messages' +import { Provider } from '../../../renderer/src/types/provider' +import { MessagesService, messagesService } from '../services/messages' import { getProviderById, validateModelId } from '../utils' const logger = loggerService.withContext('ApiServerMessagesRoutes') @@ -33,9 +34,8 @@ async function validateRequestBody(req: Request): Promise<{ valid: boolean; erro async function handleStreamingResponse( res: Response, request: MessageCreateParams, - provider: any, - messagesService: any, - logger: any + provider: Provider, + messagesService: MessagesService ): Promise { res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') res.setHeader('Cache-Control', 'no-cache, no-transform') @@ -80,7 +80,7 @@ async function handleStreamingResponse( } } -function handleErrorResponse(res: Response, error: any, logger: any): Response { +function handleErrorResponse(res: Response, error: any): Response { logger.error('Message processing error', { error }) let statusCode = 500 @@ -133,7 +133,7 @@ function handleErrorResponse(res: Response, error: any, logger: any): Response { async function processMessageRequest( req: Request, res: Response, - provider: any, + provider: Provider, modelId?: string ): Promise { try { @@ -144,17 +144,6 @@ async function processMessageRequest( request.model = modelId } - // Ensure provider is Anthropic type - if (provider.type !== 'anthropic') { - return res.status(400).json({ - type: 'error', - error: { - type: 'invalid_request_error', - message: `Invalid provider type '${provider.type}' for messages endpoint. Expected 'anthropic' provider.` - } - }) - } - // Validate request const validation = messagesService.validateRequest(request) if (!validation.isValid) { @@ -167,9 +156,14 @@ async function processMessageRequest( }) } + logger.silly('Processing message request', { + request, + provider: provider.id + }) + // Handle streaming if (request.stream) { - await handleStreamingResponse(res, request, provider, messagesService, logger) + await handleStreamingResponse(res, request, provider, messagesService) return } @@ -177,7 +171,7 @@ async function processMessageRequest( const response = await messagesService.processMessage(request, provider) return res.json(response) } catch (error: any) { - return handleErrorResponse(res, error, logger) + return handleErrorResponse(res, error) } } @@ -337,7 +331,7 @@ router.post('/', async (req: Request, res: Response) => { // Use shared processing function return await processMessageRequest(req, res, provider, modelId) } catch (error: any) { - return handleErrorResponse(res, error, logger) + return handleErrorResponse(res, error) } }) @@ -492,7 +486,7 @@ providerRouter.post('/', async (req: Request, res: Response) => { // Use shared processing function (no modelId override needed) return await processMessageRequest(req, res, provider) } catch (error: any) { - return handleErrorResponse(res, error, logger) + return handleErrorResponse(res, error) } }) diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts index 846687a77e..15b88c2154 100644 --- a/src/main/apiServer/services/models.ts +++ b/src/main/apiServer/services/models.ts @@ -13,14 +13,24 @@ export class ModelsService { try { logger.debug('Getting available models from providers', { filter }) - const models = await listAllAvailableModels() - const providers = await getAvailableProviders() + let providers = await getAvailableProviders() + if (filter.providerType === 'anthropic') { + providers = providers.filter( + (p) => p.type === 'anthropic' || (p.anthropicApiHost !== undefined && p.anthropicApiHost.trim() !== '') + ) + } + + const models = await listAllAvailableModels(providers) // Use Map to deduplicate models by their full ID (provider:model_id) const uniqueModels = new Map() for (const model of models) { - const openAIModel = transformModelToOpenAI(model, providers) + const provider = providers.find((p) => p.id === model.provider) + if (!provider || (provider.isAnthropicModel && !provider.isAnthropicModel(model))) { + continue + } + const openAIModel = transformModelToOpenAI(model, provider) const fullModelId = openAIModel.id // This is already in format "provider:model_id" // Only add if not already present (first occurrence wins) @@ -32,16 +42,6 @@ export class ModelsService { } let modelData = Array.from(uniqueModels.values()) - if (filter.providerType) { - // Apply filters - const providerType = filter.providerType - modelData = modelData.filter((model) => { - // Find the provider for this model and check its type - return model.provider_type === providerType - }) - logger.debug(`Filtered by provider type '${providerType}': ${modelData.length} models`) - } - const total = modelData.length // Apply pagination diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index 6663918927..85167d8c7d 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -47,9 +47,11 @@ export async function getAvailableProviders(): Promise { } } -export async function listAllAvailableModels(): Promise { +export async function listAllAvailableModels(providers?: Provider[]): Promise { try { - const providers = await getAvailableProviders() + if (!providers) { + providers = await getAvailableProviders() + } return providers.map((p: Provider) => p.models || []).flat() } catch (error: any) { logger.error('Failed to list available models', { error }) @@ -107,9 +109,12 @@ export interface ModelValidationError { code: string } -export async function validateModelId( - model: string -): Promise<{ valid: boolean; error?: ModelValidationError; provider?: Provider; modelId?: string }> { +export async function validateModelId(model: string): Promise<{ + valid: boolean + error?: ModelValidationError + provider?: Provider + modelId?: string +}> { try { if (!model || typeof model !== 'string') { return { @@ -192,8 +197,7 @@ export async function validateModelId( } } -export function transformModelToOpenAI(model: Model, providers: Provider[]): ApiModel { - const provider = providers.find((p) => p.id === model.provider) +export function transformModelToOpenAI(model: Model, provider?: Provider): ApiModel { const providerDisplayName = provider?.name return { id: `${model.provider}:${model.id}`, @@ -268,7 +272,10 @@ export function validateProvider(provider: Provider): boolean { return true } catch (error: any) { - logger.error('Error validating provider', { error, providerId: provider?.id }) + logger.error('Error validating provider', { + error, + providerId: provider?.id + }) return false } } diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index 73a1a1828a..cadf45b43d 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -2,7 +2,7 @@ import { type Client, createClient } from '@libsql/client' import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' import { ModelValidationError, validateModelId } from '@main/apiServer/utils' -import { AgentType, MCPTool, objectKeys, Provider, SlashCommand, Tool } from '@types' +import { AgentType, MCPTool, objectKeys, SlashCommand, Tool } from '@types' import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql' import fs from 'fs' import path from 'path' @@ -306,23 +306,6 @@ export abstract class BaseService { } ) } - - // different agent types may have different provider requirements - const agentTypeProviderRequirements: Record = { - 'claude-code': 'anthropic' - } - for (const [ak, pk] of Object.entries(agentTypeProviderRequirements)) { - if (agentType === ak && validation.provider.type !== pk) { - throw new AgentModelValidationError( - { agentType, field, model: modelValue }, - { - type: 'unsupported_provider_type', - message: `Provider type '${validation.provider.type}' is not supported for agent type '${agentType}'. Expected '${pk}'`, - code: 'unsupported_provider_type' - } - ) - } - } } } diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index cc72b3c5a7..3c7ab40bc4 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -60,7 +60,15 @@ class ClaudeCodeService implements AgentServiceInterface { }) return aiStream } - if (modelInfo.provider?.type !== 'anthropic' || modelInfo.provider.apiKey === '') { + if ( + (modelInfo.provider?.type !== 'anthropic' && + (modelInfo.provider?.anthropicApiHost === undefined || modelInfo.provider.anthropicApiHost.trim() === '')) || + modelInfo.provider.apiKey === '' + ) { + logger.error('Anthropic provider configuration is missing', { + modelInfo + }) + aiStream.emit('data', { type: 'error', error: new Error(`Invalid provider type '${modelInfo.provider?.type}'. Expected 'anthropic' provider type.`) diff --git a/src/renderer/src/components/ApiModelLabel.tsx b/src/renderer/src/components/ApiModelLabel.tsx index 4e6d318ebd..b2e08635fe 100644 --- a/src/renderer/src/components/ApiModelLabel.tsx +++ b/src/renderer/src/components/ApiModelLabel.tsx @@ -20,7 +20,7 @@ export const ApiModelLabel: React.FC = ({ model, className, cla {model?.name} | - {model?.provider_name} + {model?.provider} ) } diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index b0692e7f17..20be65ba7b 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -56,6 +56,7 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' import { AtLeast, isSystemProvider, + Model, OpenAIServiceTiers, Provider, ProviderType, @@ -105,6 +106,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = apiKey: '', apiHost: 'https://aihubmix.com', anthropicApiHost: 'https://aihubmix.com/anthropic', + isAnthropicModel: (m: Model) => m.id.includes('claude'), models: SYSTEM_MODELS.aihubmix, isSystem: true, enabled: false diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 35dd216565..4986b911fe 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2618,6 +2618,7 @@ const migrateConfig = { break case 'aihubmix': provider.anthropicApiHost = 'https://aihubmix.com/anthropic' + provider.isAnthropicModel = (m: Model) => m.id.includes('claude') break } }) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 56bc883fe8..dff9563b7f 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -253,6 +253,7 @@ export type Provider = { apiKey: string apiHost: string anthropicApiHost?: string + isAnthropicModel?: (m: Model) => boolean apiVersion?: string models: Model[] enabled?: boolean