diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 3831d0af1e..0b8db73930 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -620,7 +620,7 @@ class McpService { tools.map((tool: SDKTool) => { const serverTool: MCPTool = { ...tool, - id: buildFunctionCallToolName(server.name, tool.name), + id: buildFunctionCallToolName(server.name, tool.name, server.id), serverId: server.id, serverName: server.name, type: 'mcp' diff --git a/src/main/utils/__tests__/mcp.test.ts b/src/main/utils/__tests__/mcp.test.ts new file mode 100644 index 0000000000..b1a35f925e --- /dev/null +++ b/src/main/utils/__tests__/mcp.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' + +import { buildFunctionCallToolName } from '../mcp' + +describe('buildFunctionCallToolName', () => { + describe('basic functionality', () => { + it('should combine server name and tool name', () => { + const result = buildFunctionCallToolName('github', 'search_issues') + expect(result).toContain('github') + expect(result).toContain('search') + }) + + it('should sanitize names by replacing dashes with underscores', () => { + const result = buildFunctionCallToolName('my-server', 'my-tool') + // Input dashes are replaced, but the separator between server and tool is a dash + expect(result).toBe('my_serv-my_tool') + expect(result).toContain('_') + }) + + it('should handle empty server names gracefully', () => { + const result = buildFunctionCallToolName('', 'tool') + expect(result).toBeTruthy() + }) + }) + + describe('uniqueness with serverId', () => { + it('should generate different IDs for same server name but different serverIds', () => { + const serverId1 = 'server-id-123456' + const serverId2 = 'server-id-789012' + const serverName = 'github' + const toolName = 'search_repos' + + const result1 = buildFunctionCallToolName(serverName, toolName, serverId1) + const result2 = buildFunctionCallToolName(serverName, toolName, serverId2) + + expect(result1).not.toBe(result2) + expect(result1).toContain('123456') + expect(result2).toContain('789012') + }) + + it('should generate same ID when serverId is not provided', () => { + const serverName = 'github' + const toolName = 'search_repos' + + const result1 = buildFunctionCallToolName(serverName, toolName) + const result2 = buildFunctionCallToolName(serverName, toolName) + + expect(result1).toBe(result2) + }) + + it('should include serverId suffix when provided', () => { + const serverId = 'abc123def456' + const result = buildFunctionCallToolName('server', 'tool', serverId) + + // Should include last 6 chars of serverId + expect(result).toContain('ef456') + }) + }) + + describe('character sanitization', () => { + it('should replace invalid characters with underscores', () => { + const result = buildFunctionCallToolName('test@server', 'tool#name') + expect(result).not.toMatch(/[@#]/) + expect(result).toMatch(/^[a-zA-Z0-9_-]+$/) + }) + + it('should ensure name starts with a letter', () => { + const result = buildFunctionCallToolName('123server', '456tool') + expect(result).toMatch(/^[a-zA-Z]/) + }) + + it('should handle consecutive underscores/dashes', () => { + const result = buildFunctionCallToolName('my--server', 'my__tool') + expect(result).not.toMatch(/[_-]{2,}/) + }) + }) + + describe('length constraints', () => { + it('should truncate names longer than 63 characters', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456') + + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should not end with underscore or dash after truncation', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456') + + expect(result).not.toMatch(/[_-]$/) + }) + + it('should preserve serverId suffix even with long server/tool names', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const serverId = 'server-id-xyz789' + + const result = buildFunctionCallToolName(longServerName, longToolName, serverId) + + // The suffix should be preserved and not truncated + expect(result).toContain('xyz789') + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should ensure two long-named servers with different IDs produce different results', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const serverId1 = 'server-id-abc123' + const serverId2 = 'server-id-def456' + + const result1 = buildFunctionCallToolName(longServerName, longToolName, serverId1) + const result2 = buildFunctionCallToolName(longServerName, longToolName, serverId2) + + // Both should be within limit + expect(result1.length).toBeLessThanOrEqual(63) + expect(result2.length).toBeLessThanOrEqual(63) + + // They should be different due to preserved suffix + expect(result1).not.toBe(result2) + }) + }) + + describe('edge cases with serverId', () => { + it('should handle serverId with only non-alphanumeric characters', () => { + const serverId = '------' // All dashes + const result = buildFunctionCallToolName('server', 'tool', serverId) + + // Should still produce a valid unique suffix via fallback hash + expect(result).toBeTruthy() + expect(result.length).toBeLessThanOrEqual(63) + expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) + // Should have a suffix (underscore followed by something) + expect(result).toMatch(/_[a-z0-9]+$/) + }) + + it('should produce different results for different non-alphanumeric serverIds', () => { + const serverId1 = '------' + const serverId2 = '!!!!!!' + + const result1 = buildFunctionCallToolName('server', 'tool', serverId1) + const result2 = buildFunctionCallToolName('server', 'tool', serverId2) + + // Should be different because the hash fallback produces different values + expect(result1).not.toBe(result2) + }) + + it('should handle empty string serverId differently from undefined', () => { + const resultWithEmpty = buildFunctionCallToolName('server', 'tool', '') + const resultWithUndefined = buildFunctionCallToolName('server', 'tool', undefined) + + // Empty string is falsy, so both should behave the same (no suffix) + expect(resultWithEmpty).toBe(resultWithUndefined) + }) + + it('should handle serverId with mixed alphanumeric and special chars', () => { + const serverId = 'ab@#cd' // Mixed chars, last 6 chars contain some alphanumeric + const result = buildFunctionCallToolName('server', 'tool', serverId) + + // Should extract alphanumeric chars: 'abcd' from 'ab@#cd' + expect(result).toContain('abcd') + }) + }) + + describe('real-world scenarios', () => { + it('should handle GitHub MCP server instances correctly', () => { + const serverName = 'github' + const toolName = 'search_repositories' + + const githubComId = 'server-github-com-abc123' + const gheId = 'server-ghe-internal-xyz789' + + const tool1 = buildFunctionCallToolName(serverName, toolName, githubComId) + const tool2 = buildFunctionCallToolName(serverName, toolName, gheId) + + // Should be different + expect(tool1).not.toBe(tool2) + + // Both should be valid identifiers + expect(tool1).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) + expect(tool2).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) + + // Both should be <= 63 chars + expect(tool1.length).toBeLessThanOrEqual(63) + expect(tool2.length).toBeLessThanOrEqual(63) + }) + + it('should handle tool names that already include server name prefix', () => { + const result = buildFunctionCallToolName('github', 'github_search_repos') + expect(result).toBeTruthy() + // Should not double the server name + expect(result.split('github').length - 1).toBeLessThanOrEqual(2) + }) + }) +}) diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts index 23d19806d9..cfa700f2e6 100644 --- a/src/main/utils/mcp.ts +++ b/src/main/utils/mcp.ts @@ -1,7 +1,25 @@ -export function buildFunctionCallToolName(serverName: string, toolName: string) { +export function buildFunctionCallToolName(serverName: string, toolName: string, serverId?: string) { const sanitizedServer = serverName.trim().replace(/-/g, '_') const sanitizedTool = toolName.trim().replace(/-/g, '_') + // Calculate suffix first to reserve space for it + // Suffix format: "_" + 6 alphanumeric chars = 7 chars total + let serverIdSuffix = '' + if (serverId) { + // Take the last 6 characters of the serverId for brevity + serverIdSuffix = serverId.slice(-6).replace(/[^a-zA-Z0-9]/g, '') + + // Fallback: if suffix becomes empty (all non-alphanumeric chars), use a simple hash + if (!serverIdSuffix) { + const hash = serverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + serverIdSuffix = hash.toString(36).slice(-6) || 'x' + } + } + + // Reserve space for suffix when calculating max base name length + const SUFFIX_LENGTH = serverIdSuffix ? serverIdSuffix.length + 1 : 0 // +1 for underscore + const MAX_BASE_LENGTH = 63 - SUFFIX_LENGTH + // Combine server name and tool name let name = sanitizedTool if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) { @@ -20,9 +38,9 @@ export function buildFunctionCallToolName(serverName: string, toolName: string) // Remove consecutive underscores/dashes (optional improvement) name = name.replace(/[_-]{2,}/g, '_') - // Truncate to 63 characters maximum - if (name.length > 63) { - name = name.slice(0, 63) + // Truncate base name BEFORE adding suffix to ensure suffix is never cut off + if (name.length > MAX_BASE_LENGTH) { + name = name.slice(0, MAX_BASE_LENGTH) } // Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges @@ -30,5 +48,10 @@ export function buildFunctionCallToolName(serverName: string, toolName: string) name = name.slice(0, -1) } + // Now append the suffix - it will always fit within 63 chars + if (serverIdSuffix) { + name = `${name}_${serverIdSuffix}` + } + return name } diff --git a/src/renderer/src/aiCore/prepareParams/header.ts b/src/renderer/src/aiCore/prepareParams/header.ts index 19d4611377..615f07db35 100644 --- a/src/renderer/src/aiCore/prepareParams/header.ts +++ b/src/renderer/src/aiCore/prepareParams/header.ts @@ -17,7 +17,7 @@ export function addAnthropicHeaders(assistant: Assistant, model: Model): string[ if ( isClaude45ReasoningModel(model) && isToolUseModeFunction(assistant) && - !(isVertexProvider(provider) && isAwsBedrockProvider(provider)) + !(isVertexProvider(provider) || isAwsBedrockProvider(provider)) ) { anthropicHeaders.push(INTERLEAVED_THINKING_HEADER) } diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index dda3bd0b47..c977745a39 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -28,6 +28,7 @@ import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' import { replacePromptVariables } from '@renderer/utils/prompt' +import { isAwsBedrockProvider } from '@renderer/utils/provider' import type { ModelMessage, Tool } from 'ai' import { stepCountIs } from 'ai' @@ -175,7 +176,7 @@ export async function buildStreamTextParams( let headers: Record = options.requestOptions?.headers ?? {} - if (isAnthropicModel(model)) { + if (isAnthropicModel(model) && !isAwsBedrockProvider(provider)) { const newBetaHeaders = { 'anthropic-beta': addAnthropicHeaders(assistant, model).join(',') } headers = combineHeaders(headers, newBetaHeaders) } diff --git a/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts b/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts index 1e8b1a9547..9b2c0639e2 100644 --- a/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts @@ -1,4 +1,4 @@ -import type { Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { describe, expect, it, vi } from 'vitest' import { getAiSdkProviderId } from '../factory' @@ -68,6 +68,18 @@ function createTestProvider(id: string, type: string): Provider { } as Provider } +function createAzureProvider(id: string, apiVersion?: string, model?: string): Provider { + return { + id, + type: 'azure-openai', + name: `Azure Test ${id}`, + apiKey: 'azure-test-key', + apiHost: 'azure-test-host', + apiVersion, + models: [{ id: model || 'gpt-4' } as Model] + } +} + describe('Integrated Provider Registry', () => { describe('Provider ID Resolution', () => { it('should resolve openrouter provider correctly', () => { @@ -111,6 +123,24 @@ describe('Integrated Provider Registry', () => { const result = getAiSdkProviderId(unknownProvider) expect(result).toBe('unknown-provider') }) + + it('should handle Azure OpenAI providers correctly', () => { + const azureProvider = createAzureProvider('azure-test', '2024-02-15', 'gpt-4o') + const result = getAiSdkProviderId(azureProvider) + expect(result).toBe('azure') + }) + + it('should handle Azure OpenAI providers response endpoint correctly', () => { + const azureProvider = createAzureProvider('azure-test', 'v1', 'gpt-4o') + const result = getAiSdkProviderId(azureProvider) + expect(result).toBe('azure-responses') + }) + + it('should handle Azure provider Claude Models', () => { + const provider = createTestProvider('azure-anthropic', 'anthropic') + const result = getAiSdkProviderId(provider) + expect(result).toBe('azure-anthropic') + }) }) describe('Backward Compatibility', () => { diff --git a/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts index a832e9f632..dc26a03c80 100644 --- a/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts @@ -71,10 +71,11 @@ describe('mcp utils', () => { const result = setupToolsConfig(mcpTools) expect(result).not.toBeUndefined() - expect(Object.keys(result!)).toEqual(['test-tool']) - expect(result!['test-tool']).toHaveProperty('description') - expect(result!['test-tool']).toHaveProperty('inputSchema') - expect(result!['test-tool']).toHaveProperty('execute') + // Tools are now keyed by id (which includes serverId suffix) for uniqueness + expect(Object.keys(result!)).toEqual(['test-tool-1']) + expect(result!['test-tool-1']).toHaveProperty('description') + expect(result!['test-tool-1']).toHaveProperty('inputSchema') + expect(result!['test-tool-1']).toHaveProperty('execute') }) it('should handle multiple MCP tools', () => { @@ -109,7 +110,8 @@ describe('mcp utils', () => { expect(result).not.toBeUndefined() expect(Object.keys(result!)).toHaveLength(2) - expect(Object.keys(result!)).toEqual(['tool1', 'tool2']) + // Tools are keyed by id for uniqueness + expect(Object.keys(result!)).toEqual(['tool1-id', 'tool2-id']) }) }) @@ -135,9 +137,10 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['get-weather']) + // Tools are keyed by id for uniqueness when multiple server instances exist + expect(Object.keys(result)).toEqual(['get-weather-id']) - const tool = result['get-weather'] as Tool + const tool = result['get-weather-id'] as Tool expect(tool.description).toBe('Get weather information') expect(tool.inputSchema).toBeDefined() expect(typeof tool.execute).toBe('function') @@ -160,8 +163,8 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['no-desc-tool']) - const tool = result['no-desc-tool'] as Tool + expect(Object.keys(result)).toEqual(['no-desc-tool-id']) + const tool = result['no-desc-tool-id'] as Tool expect(tool.description).toBe('Tool from test-server') }) @@ -202,13 +205,13 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['complex-tool']) - const tool = result['complex-tool'] as Tool + expect(Object.keys(result)).toEqual(['complex-tool-id']) + const tool = result['complex-tool-id'] as Tool expect(tool.inputSchema).toBeDefined() expect(typeof tool.execute).toBe('function') }) - it('should preserve tool names with special characters', () => { + it('should preserve tool id with special characters', () => { const mcpTools: MCPTool[] = [ { id: 'special-tool-id', @@ -225,7 +228,8 @@ describe('mcp utils', () => { ] const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['tool_with-special.chars']) + // Tools are keyed by id for uniqueness + expect(Object.keys(result)).toEqual(['special-tool-id']) }) it('should handle multiple tools with different schemas', () => { @@ -276,10 +280,11 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result).sort()).toEqual(['boolean-tool', 'number-tool', 'string-tool']) - expect(result['string-tool']).toBeDefined() - expect(result['number-tool']).toBeDefined() - expect(result['boolean-tool']).toBeDefined() + // Tools are keyed by id for uniqueness + expect(Object.keys(result).sort()).toEqual(['boolean-tool-id', 'number-tool-id', 'string-tool-id']) + expect(result['string-tool-id']).toBeDefined() + expect(result['number-tool-id']).toBeDefined() + expect(result['boolean-tool-id']).toBeDefined() }) }) @@ -310,7 +315,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['test-exec-tool'] as Tool + const tool = tools['test-exec-tool-id'] as Tool const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'test-call-123' }) expect(requestToolConfirmation).toHaveBeenCalled() @@ -343,7 +348,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['cancelled-tool'] as Tool + const tool = tools['cancelled-tool-id'] as Tool const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'cancel-call-123' }) expect(requestToolConfirmation).toHaveBeenCalled() @@ -385,7 +390,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['error-tool'] as Tool + const tool = tools['error-tool-id'] as Tool await expect( tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'error-call-123' }) @@ -421,7 +426,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['auto-approve-tool'] as Tool + const tool = tools['auto-approve-tool-id'] as Tool const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'auto-call-123' }) expect(requestToolConfirmation).not.toHaveBeenCalled() diff --git a/src/renderer/src/aiCore/utils/__tests__/options.test.ts b/src/renderer/src/aiCore/utils/__tests__/options.test.ts index 8f2629f4d8..ca6b883d74 100644 --- a/src/renderer/src/aiCore/utils/__tests__/options.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/options.test.ts @@ -154,6 +154,10 @@ vi.mock('../websearch', () => ({ getWebSearchParams: vi.fn(() => ({ enable_search: true })) })) +vi.mock('../../prepareParams/header', () => ({ + addAnthropicHeaders: vi.fn(() => ['context-1m-2025-08-07']) +})) + const ensureWindowApi = () => { const globalWindow = window as any globalWindow.api = globalWindow.api || {} @@ -633,5 +637,64 @@ describe('options utils', () => { expect(result.providerOptions).toHaveProperty('anthropic') }) }) + + describe('AWS Bedrock provider', () => { + const bedrockProvider = { + id: 'bedrock', + name: 'AWS Bedrock', + type: 'aws-bedrock', + apiKey: 'test-key', + apiHost: 'https://bedrock.us-east-1.amazonaws.com', + models: [] as Model[] + } as Provider + + const bedrockModel: Model = { + id: 'anthropic.claude-sonnet-4-20250514-v1:0', + name: 'Claude Sonnet 4', + provider: 'bedrock' + } as Model + + it('should build basic Bedrock options', () => { + const result = buildProviderOptions(mockAssistant, bedrockModel, bedrockProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.providerOptions).toHaveProperty('bedrock') + expect(result.providerOptions.bedrock).toBeDefined() + }) + + it('should include anthropicBeta when Anthropic headers are needed', async () => { + const { addAnthropicHeaders } = await import('../../prepareParams/header') + vi.mocked(addAnthropicHeaders).mockReturnValue(['interleaved-thinking-2025-05-14', 'context-1m-2025-08-07']) + + const result = buildProviderOptions(mockAssistant, bedrockModel, bedrockProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.providerOptions.bedrock).toHaveProperty('anthropicBeta') + expect(result.providerOptions.bedrock.anthropicBeta).toEqual([ + 'interleaved-thinking-2025-05-14', + 'context-1m-2025-08-07' + ]) + }) + + it('should include reasoning parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, bedrockModel, bedrockProvider, { + enableReasoning: true, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.providerOptions.bedrock).toHaveProperty('reasoningConfig') + expect(result.providerOptions.bedrock.reasoningConfig).toEqual({ + type: 'enabled', + budgetTokens: 5000 + }) + }) + }) }) }) diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts index 84bc661aa0..7d3be9ac96 100644 --- a/src/renderer/src/aiCore/utils/mcp.ts +++ b/src/renderer/src/aiCore/utils/mcp.ts @@ -28,7 +28,9 @@ export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): ToolSet { const tools: ToolSet = {} for (const mcpTool of mcpTools) { - tools[mcpTool.name] = tool({ + // Use mcpTool.id (which includes serverId suffix) to ensure uniqueness + // when multiple instances of the same MCP server type are configured + tools[mcpTool.id] = tool({ description: mcpTool.description || `Tool from ${mcpTool.serverName}`, inputSchema: jsonSchema(mcpTool.inputSchema as JSONSchema7), execute: async (params, { toolCallId }) => { diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 4fb6f07e1f..a1352a801a 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -36,6 +36,7 @@ import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@rende import type { JSONValue } from 'ai' import { t } from 'i18next' +import { addAnthropicHeaders } from '../prepareParams/header' import { getAiSdkProviderId } from '../provider/factory' import { buildGeminiGenerateImageParams } from './image' import { @@ -469,6 +470,11 @@ function buildBedrockProviderOptions( } } + const betaHeaders = addAnthropicHeaders(assistant, model) + if (betaHeaders.length > 0) { + providerOptions.anthropicBeta = betaHeaders + } + return providerOptions } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx index df4dbb0485..3924d6b57f 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx @@ -57,7 +57,7 @@ const PopupContainer: React.FC = ({ model, apiFilter, modelFilter, showTa const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) const { models, isLoading } = useApiModels(apiFilter) - const adaptedModels = models.map((model) => apiModelAdapter(model)) + const adaptedModels = useMemo(() => models.map((model) => apiModelAdapter(model)), [models]) // 当前选中的模型ID const currentModelId = model ? model.id : '' diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index d711659f98..3e8b268a64 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -309,11 +309,14 @@ describe('Ling Models', () => { describe('Claude & regional providers', () => { it('identifies claude 4.5 variants', () => { expect(isClaude45ReasoningModel(createModel({ id: 'claude-sonnet-4.5-preview' }))).toBe(true) + expect(isClaude4SeriesModel(createModel({ id: 'claude-sonnet-4-5@20250929' }))).toBe(true) expect(isClaude45ReasoningModel(createModel({ id: 'claude-3-sonnet' }))).toBe(false) }) it('identifies claude 4 variants', () => { expect(isClaude4SeriesModel(createModel({ id: 'claude-opus-4' }))).toBe(true) + expect(isClaude4SeriesModel(createModel({ id: 'claude-sonnet-4@20250514' }))).toBe(true) + expect(isClaude4SeriesModel(createModel({ id: 'anthropic.claude-sonnet-4-20250514-v1:0' }))).toBe(true) expect(isClaude4SeriesModel(createModel({ id: 'claude-4.2-sonnet-variant' }))).toBe(false) expect(isClaude4SeriesModel(createModel({ id: 'claude-3-haiku' }))).toBe(false) }) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 7572d56fde..dab918d5fd 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -396,7 +396,11 @@ export function isClaude45ReasoningModel(model: Model): boolean { export function isClaude4SeriesModel(model: Model): boolean { const modelId = getLowerBaseModelName(model.id, '/') - const regex = /claude-(sonnet|opus|haiku)-4(?:[.-]\d+)?(?:-[\w-]+)?$/i + // Supports various formats including: + // - Direct API: claude-sonnet-4, claude-opus-4-20250514 + // - GCP Vertex AI: claude-sonnet-4@20250514 + // - AWS Bedrock: anthropic.claude-sonnet-4-20250514-v1:0 + const regex = /claude-(sonnet|opus|haiku)-4(?:[.-]\d+)?(?:[@\-:][\w\-:]+)?$/i return regex.test(modelId) } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx index b47bb3f64a..39d72abcf8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx @@ -76,7 +76,7 @@ export function BashOutputTool({ input, output }: { - input: BashOutputToolInput + input?: BashOutputToolInput output?: BashOutputToolOutput }): NonNullable[number] { const parsedOutput = parseBashOutput(output) @@ -144,7 +144,7 @@ export function BashOutputTool({ label="Bash Output" params={
- {input.bash_id} + {input?.bash_id} {statusConfig && ( [number] { // 如果有输出,计算输出行数 const outputLines = output ? output.split('\n').length : 0 - // 处理命令字符串的截断 - const command = input.command + // 处理命令字符串的截断,添加空值检查 + const command = input?.command ?? '' const needsTruncate = command.length > MAX_TAG_LENGTH const displayCommand = needsTruncate ? `${command.slice(0, MAX_TAG_LENGTH)}...` : command @@ -31,7 +31,7 @@ export function BashTool({ } label="Bash" - params={input.description} + params={input?.description} stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined} />
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx index a49a89664d..3eff8118ef 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx @@ -32,19 +32,19 @@ export function EditTool({ input, output }: { - input: EditToolInput + input?: EditToolInput output?: EditToolOutput }): NonNullable[number] { return { key: AgentToolsType.Edit, - label: } label="Edit" params={input.file_path} />, + label: } label="Edit" params={input?.file_path} />, children: ( <> {/* Diff View */} {/* Old Content */} - {renderCodeBlock(input.old_string, 'old')} + {renderCodeBlock(input?.old_string ?? '', 'old')} {/* New Content */} - {renderCodeBlock(input.new_string, 'new')} + {renderCodeBlock(input?.new_string ?? '', 'new')} {/* Output */} {output} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx index 0c0a4ec4a7..f92116478d 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx @@ -10,18 +10,19 @@ export function ExitPlanModeTool({ input, output }: { - input: ExitPlanModeToolInput + input?: ExitPlanModeToolInput output?: ExitPlanModeToolOutput }): NonNullable[number] { + const plan = input?.plan ?? '' return { key: AgentToolsType.ExitPlanMode, label: ( } label="ExitPlanMode" - stats={`${input.plan.split('\n\n').length} plans`} + stats={`${plan.split('\n\n').length} plans`} /> ), - children: {input.plan + '\n\n' + (output ?? '')} + children: {plan + '\n\n' + (output ?? '')} } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx index 97e816be1d..b70d6da40e 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx @@ -8,7 +8,7 @@ export function GlobTool({ input, output }: { - input: GlobToolInputType + input?: GlobToolInputType output?: GlobToolOutputType }): NonNullable[number] { // 如果有输出,计算文件数量 @@ -20,7 +20,7 @@ export function GlobTool({ } label="Glob" - params={input.pattern} + params={input?.pattern} stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined} /> ), diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx index dbf7e0bbf1..16149549df 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx @@ -8,7 +8,7 @@ export function GrepTool({ input, output }: { - input: GrepToolInput + input?: GrepToolInput output?: GrepToolOutput }): NonNullable[number] { // 如果有输出,计算结果行数 @@ -22,8 +22,8 @@ export function GrepTool({ label="Grep" params={ <> - {input.pattern} - {input.output_mode && ({input.output_mode})} + {input?.pattern} + {input?.output_mode && ({input.output_mode})} } stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx index 546fd439dc..00922126e7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx @@ -9,18 +9,19 @@ import { AgentToolsType } from './types' export function MultiEditTool({ input }: { - input: MultiEditToolInput + input?: MultiEditToolInput output?: MultiEditToolOutput }): NonNullable[number] { + const edits = Array.isArray(input?.edits) ? input.edits : [] return { key: AgentToolsType.MultiEdit, - label: } label="MultiEdit" params={input.file_path} />, + label: } label="MultiEdit" params={input?.file_path} />, children: (
- {input.edits.map((edit, index) => ( + {edits.map((edit, index) => (
- {renderCodeBlock(edit.old_string, 'old')} - {renderCodeBlock(edit.new_string, 'new')} + {renderCodeBlock(edit.old_string ?? '', 'old')} + {renderCodeBlock(edit.new_string ?? '', 'new')}
))}
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx index 8f9eb36a2e..fe0638f3c9 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx @@ -11,7 +11,7 @@ export function NotebookEditTool({ input, output }: { - input: NotebookEditToolInput + input?: NotebookEditToolInput output?: NotebookEditToolOutput }): NonNullable[number] { return { @@ -20,10 +20,10 @@ export function NotebookEditTool({ <> } label="NotebookEdit" /> - {input.notebook_path}{' '} + {input?.notebook_path}{' '} ), - children: {output} + children: {output ?? ''} } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx index 043d8a94c4..30ae162276 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx @@ -46,7 +46,7 @@ export function ReadTool({ input, output }: { - input: ReadToolInputType + input?: ReadToolInputType output?: ReadToolOutputType }): NonNullable[number] { const outputString = normalizeOutputString(output) @@ -58,7 +58,7 @@ export function ReadTool({ } label="Read File" - params={input.file_path.split('/').pop()} + params={input?.file_path?.split('/').pop()} stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined} /> ), diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx index 8eda9dea5f..66bf28c671 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx @@ -8,7 +8,7 @@ export function SearchTool({ input, output }: { - input: SearchToolInputType + input?: SearchToolInputType output?: SearchToolOutputType }): NonNullable[number] { // 如果有输出,计算结果数量 @@ -20,13 +20,13 @@ export function SearchTool({ } label="Search" - params={`"${input}"`} + params={input ? `"${input}"` : undefined} stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} /> ), children: (
- + {input && } {output && (
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx index 1c0651a9e1..6127984676 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx @@ -8,12 +8,12 @@ export function SkillTool({ input, output }: { - input: SkillToolInput + input?: SkillToolInput output?: SkillToolOutput }): NonNullable[number] { return { key: 'tool', - label: } label="Skill" params={input.command} />, + label: } label="Skill" params={input?.command} />, children:
{output}
} } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx index 2c5a4a1c73..18117590c7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx @@ -9,19 +9,20 @@ export function TaskTool({ input, output }: { - input: TaskToolInputType + input?: TaskToolInputType output?: TaskToolOutputType }): NonNullable[number] { return { key: 'tool', - label: } label="Task" params={input.description} />, + label: } label="Task" params={input?.description} />, children: (
- {output?.map((item) => ( -
-
{item.type === 'text' ? {item.text} : item.text}
-
- ))} + {Array.isArray(output) && + output.map((item) => ( +
+
{item.type === 'text' ? {item.text} : item.text}
+
+ ))}
) } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx index 2796e44fc9..a81de46dcd 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx @@ -38,9 +38,10 @@ const getStatusConfig = (status: TodoItem['status']) => { export function TodoWriteTool({ input }: { - input: TodoWriteToolInputType + input?: TodoWriteToolInputType }): NonNullable[number] { - const doneCount = input.todos.filter((todo) => todo.status === 'completed').length + const todos = Array.isArray(input?.todos) ? input.todos : [] + const doneCount = todos.filter((todo) => todo.status === 'completed').length return { key: AgentToolsType.TodoWrite, @@ -49,12 +50,12 @@ export function TodoWriteTool({ icon={} label="Todo Write" params={`${doneCount} Done`} - stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`} + stats={`${todos.length} ${todos.length === 1 ? 'item' : 'items'}`} /> ), children: (
- {input.todos.map((todo, index) => { + {todos.map((todo, index) => { const statusConfig = getStatusConfig(todo.status) return (
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx index f54c541459..f8bd27df5f 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx @@ -8,12 +8,12 @@ export function WebFetchTool({ input, output }: { - input: WebFetchToolInput + input?: WebFetchToolInput output?: WebFetchToolOutput }): NonNullable[number] { return { key: 'tool', - label: } label="Web Fetch" params={input.url} />, + label: } label="Web Fetch" params={input?.url} />, children:
{output}
} } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx index 7042c63afb..4f50839cc9 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx @@ -8,7 +8,7 @@ export function WebSearchTool({ input, output }: { - input: WebSearchToolInput + input?: WebSearchToolInput output?: WebSearchToolOutput }): NonNullable[number] { // 如果有输出,计算结果数量 @@ -20,7 +20,7 @@ export function WebSearchTool({ } label="Web Search" - params={input.query} + params={input?.query} stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} /> ), diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx index d035163dcc..fd0d637f50 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx @@ -7,12 +7,12 @@ import type { WriteToolInput, WriteToolOutput } from './types' export function WriteTool({ input }: { - input: WriteToolInput + input?: WriteToolInput output?: WriteToolOutput }): NonNullable[number] { return { key: 'tool', - label: } label="Write" params={input.file_path} />, - children:
{input.content}
+ label: } label="Write" params={input?.file_path} />, + children:
{input?.content}
} } diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 31dcfe437e..57dac8c78a 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -9,7 +9,7 @@ import { DEFAULT_TEMPERATURE, MAX_CONTEXT_COUNT } from '@renderer/config/constant' -import { isOpenAIModel } from '@renderer/config/models' +import { isOpenAIModel, isSupportVerbosityModel } from '@renderer/config/models' import { UNKNOWN } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useTheme } from '@renderer/context/ThemeProvider' @@ -56,7 +56,7 @@ import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from import { isGroqSystemProvider, ThemeMode } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' -import { isSupportServiceTierProvider } from '@renderer/utils/provider' +import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@renderer/utils/provider' import { Button, Col, InputNumber, Row, Slider, Switch } from 'antd' import { Settings2 } from 'lucide-react' import type { FC } from 'react' @@ -183,7 +183,10 @@ const SettingsTab: FC = (props) => { const model = assistant.model || getDefaultModel() - const showOpenAiSettings = isOpenAIModel(model) || isSupportServiceTierProvider(provider) + const showOpenAiSettings = + isOpenAIModel(model) || + isSupportServiceTierProvider(provider) || + (isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider)) return ( diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts index 2c79d36352..304ef81e39 100644 --- a/src/renderer/src/utils/provider.ts +++ b/src/renderer/src/utils/provider.ts @@ -81,15 +81,21 @@ export const isSupportEnableThinkingProvider = (provider: Provider) => { ) } -const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[] +const SUPPORT_SERVICE_TIER_PROVIDERS = [ + SystemProviderIds.openai, + SystemProviderIds['azure-openai'], + SystemProviderIds.groq + // TODO: 等待上游支持aws-bedrock +] /** - * 判断提供商是否支持 service_tier 设置。 Only for OpenAI API. + * 判断提供商是否支持 service_tier 设置 */ export const isSupportServiceTierProvider = (provider: Provider) => { return ( provider.apiOptions?.isSupportServiceTier === true || - (isSystemProvider(provider) && !NOT_SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id)) + provider.type === 'azure-openai' || + (isSystemProvider(provider) && SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id)) ) } @@ -112,6 +118,7 @@ const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [ 'gemini', 'vertexai', 'anthropic', + 'azure-openai', 'new-api' ] as const satisfies ProviderType[]