fix(stage-ui): tools not available when using remote API (#1468)

This commit is contained in:
Doji
2026-03-25 01:18:15 +08:00
committed by GitHub
parent ea10e2c3d5
commit 74fd0e1701
8 changed files with 114 additions and 207 deletions
@@ -11,7 +11,7 @@ import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consci
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { BasicTextarea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { widgetsTools } from '../stores/tools/builtin/widgets'
@@ -23,7 +23,7 @@ const chatOrchestrator = useChatOrchestratorStore()
const chatSession = useChatSessionStore()
const chatStream = useChatStreamStore()
const { cleanupMessages } = useChatMaintenanceStore()
const { ingest, onAfterMessageComposed, discoverToolsCompatibility } = chatOrchestrator
const { ingest, onAfterMessageComposed } = chatOrchestrator
const { messages } = storeToRefs(chatSession)
const { streamingMessage } = storeToRefs(chatStream)
const { sending } = storeToRefs(chatOrchestrator)
@@ -103,12 +103,6 @@ function removeAttachment(index: number) {
}
}
watch([activeProvider, activeModel], async () => {
if (activeProvider.value && activeModel.value) {
await discoverToolsCompatibility(activeModel.value, await providersStore.getProviderInstance<ChatProvider>(activeProvider.value), [])
}
}, { immediate: true })
onAfterMessageComposed(async () => {
messageInput.value = ''
attachments.value.forEach(att => URL.revokeObjectURL(att.url))
@@ -52,7 +52,7 @@ useResizeObserver(document.documentElement, () => screenSafeArea.update())
const { themeColorsHueDynamic, stageViewControlsEnabled } = storeToRefs(useSettings())
const settingsAudioDevice = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
const { ingest, onAfterMessageComposed, discoverToolsCompatibility } = chatOrchestrator
const { ingest, onAfterMessageComposed } = chatOrchestrator
const { t } = useI18n()
const { audioContext } = useAudioContext()
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
@@ -130,12 +130,6 @@ watch(hearingDialogOpen, (value) => {
onAfterMessageComposed(async () => {
})
watch([activeProvider, activeModel], async () => {
if (activeProvider.value && activeModel.value) {
await discoverToolsCompatibility(activeModel.value, await providersStore.getProviderInstance<ChatProvider>(activeProvider.value), [])
}
}, { immediate: true })
onUnmounted(() => {
teardownAnalyzer()
})
@@ -32,7 +32,7 @@ const { askPermission, startStream } = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
const chatOrchestrator = useChatOrchestratorStore()
const chatSession = useChatSessionStore()
const { ingest, onAfterMessageComposed, discoverToolsCompatibility } = chatOrchestrator
const { ingest, onAfterMessageComposed } = chatOrchestrator
const { messages } = storeToRefs(chatSession)
const { audioContext } = useAudioContext()
const { t } = useI18n()
@@ -134,12 +134,6 @@ watch(hearingPopoverOpen, async (value) => {
}
})
watch([activeProvider, activeModel], async () => {
if (activeProvider.value && activeModel.value) {
await discoverToolsCompatibility(activeModel.value, await providersStore.getProviderInstance<ChatProvider>(activeProvider.value), [])
}
}, { immediate: true })
onAfterMessageComposed(async () => {
})
-2
View File
@@ -413,8 +413,6 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
return {
sending,
discoverToolsCompatibility: llmStore.discoverToolsCompatibility,
ingest,
ingestOnFork,
cancelPendingSends,
+43 -31
View File
@@ -1,37 +1,49 @@
import { env } from 'node:process'
import { createOpenRouter } from '@xsai-ext/providers/create'
import { describe, expect, it } from 'vitest'
import { attemptForToolsCompatibilityDiscovery } from './llm'
import { isToolRelatedError } from './llm'
function doesHaveOpenRouterApiKey() {
const apiKey = env.LLM_API_OPENROUTER_API_KEY
if (!apiKey) {
console.warn('Skipping llm store tests, because LLM_API_OPENROUTER_API_KEY is not set')
describe('isToolRelatedError', () => {
const positives: [provider: string, msg: string][] = [
['ollama', 'llama3 does not support tools'],
['ollama', 'phi does not support tools'],
['openrouter', 'No endpoints found that support tool use'],
['openai-compatible', 'Invalid schema for function \'myFunc\': \'dict\' is not valid under any of the given schemas'],
['openai-compatible', 'invalid_function_parameters'],
['openai-compatible', 'invalid function parameters'],
['azure', 'Functions are not supported at this time'],
['azure', 'Unrecognized request argument supplied: tools'],
['azure', 'Unrecognized request arguments supplied: tool_choice, tools'],
['google', 'Tool use with function calling is unsupported'],
['groq', 'tool_use_failed'],
['groq', 'Error code: tool_use_failed - Failed to call a function'],
['anthropic', 'This model does not support function calling'],
['anthropic', 'does not support function_calling'],
['cloudflare', 'tools is not supported'],
['cloudflare', 'tool is not supported for this model'],
['cloudflare', 'tools are not supported'],
]
const negatives = [
'network error',
'timeout',
'rate limit exceeded',
'invalid api key',
'model not found',
'context length exceeded',
'',
]
for (const [provider, msg] of positives) {
it(`matches [${provider}]: "${msg}"`, () => {
expect(isToolRelatedError(msg)).toBe(true)
expect(isToolRelatedError(new Error(msg))).toBe(true)
})
}
return !!apiKey
}
const hasOpenRouterApiKey = doesHaveOpenRouterApiKey()
describe.skipIf(!hasOpenRouterApiKey)('llm store', { timeout: 60000 }, async () => {
it('should be false for phi-4', async () => {
// TODO: base url should not be hardcoded, wait for https://github.com/moeru-ai/xsai/pull/194
const res1 = await attemptForToolsCompatibilityDiscovery('microsoft/phi-4', createOpenRouter(env.LLM_API_OPENROUTER_API_KEY!, 'https://openrouter.ai/api/v1/'), [])
expect(res1).toBe(false)
})
it('should be false for gpt-4o-mini', async () => {
// TODO: base url should not be hardcoded, wait for https://github.com/moeru-ai/xsai/pull/194
const res1 = await attemptForToolsCompatibilityDiscovery('openai/gpt-4o-mini', createOpenRouter(env.LLM_API_OPENROUTER_API_KEY!, 'https://openrouter.ai/api/v1/'), [])
expect(res1).toBe(false)
})
it('should be true for gpt-4o', async () => {
// TODO: base url should not be hardcoded, wait for https://github.com/moeru-ai/xsai/pull/194
const res2 = await attemptForToolsCompatibilityDiscovery('openai/gpt-4o', createOpenRouter(env.LLM_API_OPENROUTER_API_KEY!, 'https://openrouter.ai/api/v1/'), [])
expect(res2).toBe(true)
})
for (const msg of negatives) {
it(`rejects: "${msg}"`, () => {
expect(isToolRelatedError(msg)).toBe(false)
expect(isToolRelatedError(new Error(msg))).toBe(false)
})
}
})
+47 -111
View File
@@ -2,7 +2,6 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { CommonContentPart, CompletionToolCall, Message, Tool } from '@xsai/shared-chat'
import { listModels } from '@xsai/model'
import { XSAIError } from '@xsai/shared'
import { streamText } from '@xsai/stream-text'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -22,11 +21,10 @@ export interface StreamOptions {
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
toolsCompatibility?: Map<string, boolean>
supportsTools?: boolean
waitForTools?: boolean // when true,won't resolve on finishReason=='tool_calls';
waitForTools?: boolean
tools?: Tool[] | (() => Promise<Tool[] | undefined>)
}
// TODO: proper format for other error messages.
function sanitizeMessages(messages: unknown[]): Message[] {
return messages.map((m: any) => {
if (m && m.role === 'error') {
@@ -35,10 +33,8 @@ function sanitizeMessages(messages: unknown[]): Message[] {
content: `User encountered error: ${String(m.content ?? '')}`,
} as Message
}
// NOTICE: This block is critical for backward compatibility with LLM providers (e.g., DeepSeek)
// that expect message content to be a string, not an array of content parts.
// Failure to flatten array content (when no image_url is present) can lead to
// deserialization errors like "invalid type: sequence, expected a string".
// NOTICE: Flatten array content for providers (e.g. DeepSeek) that expect string,
// not content-part arrays. Skipped when image_url parts are present.
if (m && Array.isArray(m.content)) {
const contentParts = m.content as { type?: string, text?: string }[]
if (!contentParts.some(p => p?.type === 'image_url')) {
@@ -50,14 +46,16 @@ function sanitizeMessages(messages: unknown[]): Message[] {
}
function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProvider, _: Message[], options?: StreamOptions): boolean {
return !!(options?.supportsTools || options?.toolsCompatibility?.get(`${chatProvider.chat(model).baseURL}-${model}`))
if (options?.supportsTools)
return true
const key = `${chatProvider.chat(model).baseURL}-${model}`
return options?.toolsCompatibility?.get(key) !== false
}
async function streamFrom(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
const headers = options?.headers
const chatConfig = chatProvider.chat(model)
const sanitized = sanitizeMessages(messages as unknown[])
const resolveTools = async () => {
const tools = typeof options?.tools === 'function'
? await options.tools()
@@ -98,8 +96,7 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M
resolveOnce()
}
else if (event && (event as StreamEvent).type === 'error') {
const error = (event as any).error ?? new Error('Stream error')
rejectOnce(error)
rejectOnce((event as any).error ?? new Error('Stream error'))
}
}
catch (err) {
@@ -113,30 +110,20 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M
abortSignal: options?.abortSignal,
maxSteps: 10,
messages: sanitized,
headers,
// TODO: we need Automatic tools discovery
headers: options?.headers,
tools,
onEvent,
})
// NOTICE: @xsai/stream-text rejects its result promises when the SSE parser
// encounters provider-side errors such as `{"error":{"message":"failed to process image"}}`.
// This wrapper resolves/rejects via `onEvent`, so we must also consume the
// underlying promises to prevent those internal rejections from surfacing as
// unhandled promise errors and leaving the app in a faulted state.
// NOTICE: Consume underlying promises to prevent unhandled rejections from
// @xsai/stream-text's SSE parser surfacing as faulted app state.
void streamResult.steps.catch((err) => {
rejectOnce(err)
console.error('Stream steps error:', err)
})
void streamResult.messages.catch((err) => {
console.error('Stream messages error:', err)
})
void streamResult.usage.catch((err) => {
console.error('Stream usage error:', err)
})
void streamResult.totalUsage.catch((err) => {
console.error('Stream totalUsage error:', err)
})
void streamResult.messages.catch(err => console.error('Stream messages error:', err))
void streamResult.usage.catch(err => console.error('Stream usage error:', err))
void streamResult.totalUsage.catch(err => console.error('Stream totalUsage error:', err))
}
catch (err) {
rejectOnce(err)
@@ -144,97 +131,49 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M
})
}
export async function attemptForToolsCompatibilityDiscovery(model: string, chatProvider: ChatProvider, _: Message[], options?: Omit<StreamOptions, 'supportsTools'>): Promise<boolean> {
async function attempt(enable: boolean) {
try {
await streamFrom(model, chatProvider, [{ role: 'user', content: 'Hello, world!' }], { ...options, supportsTools: enable })
return true
}
catch (err) {
if (err instanceof Error && err.name === new XSAIError('').name) {
// TODO: if you encountered many more errors like these, please, add them here.
// Runtime auto-degrade: patterns that indicate the model/provider does not support tool calling.
const TOOLS_RELATED_ERROR_PATTERNS: RegExp[] = [
/does not support tools/i, // Ollama
/no endpoints found that support tool use/i, // OpenRouter
/invalid schema for function/i, // OpenAI-compatible
/invalid.?function.?parameters/i, // OpenAI-compatible
/functions are not supported/i, // Azure AI Foundry
/unrecognized request argument.+tools/i, // Azure AI Foundry
/tool use with function calling is unsupported/i, // Google Generative AI
/tool_use_failed/i, // Groq
/does not support function.?calling/i, // Anthropic
/tools?\s+(is|are)\s+not\s+supported/i, // Cloudflare Workers AI
]
// Ollama
/**
* {"error":{"message":"registry.ollama.ai/<scope>/<model> does not support tools","type":"api_error","param":null,"code":null}}
*/
if (String(err).includes('does not support tools')) {
return false
}
// OpenRouter
/**
* {"error":{"message":"No endpoints found that support tool use. To learn more about provider routing, visit: https://openrouter.ai/docs/provider-routing","code":404}}
*/
if (String(err).includes('No endpoints found that support tool use.')) {
return false
}
}
throw err
}
}
function promiseAllWithInterval<T>(promises: (() => Promise<T>)[], interval: number): Promise<{ result?: T, error?: any }[]> {
return new Promise((resolve) => {
const results: { result?: T, error?: any }[] = []
let completed = 0
promises.forEach((promiseFn, index) => {
setTimeout(() => {
promiseFn()
.then((result) => {
results[index] = { result }
})
.catch((err) => {
results[index] = { error: err }
})
.finally(() => {
completed++
if (completed === promises.length) {
resolve(results)
}
})
}, index * interval)
})
})
}
const attempts = [
() => attempt(true),
() => attempt(false),
]
const attemptsResults = await promiseAllWithInterval<boolean | undefined>(attempts, 1000)
if (attemptsResults.some(res => res.error)) {
const err = new Error(`Error during tools compatibility discovery for model: ${model}. Errors: ${attemptsResults.map(res => res.error).filter(Boolean).join(', ')}`)
err.cause = attemptsResults.map(res => res.error).filter(Boolean)
throw err
}
return attemptsResults[0].result === true && attemptsResults[1].result === true
export function isToolRelatedError(err: unknown): boolean {
const msg = String(err)
return TOOLS_RELATED_ERROR_PATTERNS.some(p => p.test(msg))
}
export const useLLM = defineStore('llm', () => {
const toolsCompatibility = ref<Map<string, boolean>>(new Map())
async function discoverToolsCompatibility(model: string, chatProvider: ChatProvider, _: Message[], options?: Omit<StreamOptions, 'supportsTools'>) {
// Cached, no need to discover again
if (toolsCompatibility.value.has(`${chatProvider.chat(model).baseURL}-${model}`)) {
return
}
const res = await attemptForToolsCompatibilityDiscovery(model, chatProvider, _, { ...options, toolsCompatibility: toolsCompatibility.value })
toolsCompatibility.value.set(`${chatProvider.chat(model).baseURL}-${model}`, res)
function modelKey(model: string, chatProvider: ChatProvider): string {
return `${chatProvider.chat(model).baseURL}-${model}`
}
function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
return streamFrom(model, chatProvider, messages, { ...options, toolsCompatibility: toolsCompatibility.value })
async function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
const key = modelKey(model, chatProvider)
try {
await streamFrom(model, chatProvider, messages, { ...options, toolsCompatibility: toolsCompatibility.value })
}
catch (err) {
if (isToolRelatedError(err)) {
console.warn(`[llm] Auto-disabling tools for "${key}" due to tool-related error`)
toolsCompatibility.value.set(key, false)
}
throw err
}
}
async function models(apiUrl: string, apiKey: string) {
if (apiUrl === '') {
if (apiUrl === '')
return []
}
try {
return await listModels({
@@ -243,10 +182,8 @@ export const useLLM = defineStore('llm', () => {
})
}
catch (err) {
if (String(err).includes(`Failed to construct 'URL': Invalid URL`)) {
if (String(err).includes(`Failed to construct 'URL': Invalid URL`))
return []
}
throw err
}
}
@@ -254,6 +191,5 @@ export const useLLM = defineStore('llm', () => {
return {
models,
stream,
discoverToolsCompatibility,
}
})
+9 -14
View File
@@ -7,25 +7,20 @@ import { mcp } from './mcp'
describe('tools mcp schema', () => {
it('emits strict parameter objects', async () => {
const tools = await mcp()
const toolNames = [
'mcp_list_tools',
'mcp_call_tool',
]
for (const name of toolNames) {
const tool = tools.find(entry => entry.function.name === name)
expect(tool, `missing tool: ${name}`).toBeDefined()
expect(tool?.function.parameters.additionalProperties).toBe(false)
for (const name of ['mcp_list_tools', 'mcp_call_tool']) {
const t = tools.find(entry => entry.function.name === name)
expect(t, `missing tool: ${name}`).toBeDefined()
expect(t?.function.parameters.additionalProperties).toBe(false)
}
})
it('keeps mcp_call_tool parameters items strict', async () => {
it('mcp_call_tool uses flat name+arguments schema', async () => {
const tools = await mcp()
const callTool = tools.find(entry => entry.function.name === 'mcp_call_tool')
expect(callTool).toBeDefined()
const items = ((callTool!.function.parameters as JsonSchema).properties?.parameters as JsonSchema)?.items as JsonSchema
expect(items).toBeDefined()
expect(items.additionalProperties).toBe(false)
const props = (callTool!.function.parameters as JsonSchema).properties!
expect((props.name as JsonSchema).type).toBe('string')
expect((props.arguments as JsonSchema).type).toBe('string')
})
})
+11 -27
View File
@@ -6,8 +6,8 @@ import { getMcpToolBridge } from '../stores/mcp-tool-bridge'
const tools = [
tool({
name: 'mcp_list_tools',
description: 'List all tools available on the connected MCP servers',
execute: async (_, __) => {
description: 'List all available MCP tools. Call this first to discover tool names before calling mcp_call_tool.',
execute: async () => {
try {
return await getMcpToolBridge().listTools()
}
@@ -20,40 +20,24 @@ const tools = [
}),
tool({
name: 'mcp_call_tool',
description: 'Call a tool on the MCP server. The result is a list of content and a boolean indicating whether the tool call is an error.',
execute: async ({ name, parameters }) => {
description: 'Call an MCP tool by name. Use mcp_list_tools first to get available tool names.',
execute: async ({ name, arguments: argsJson }) => {
try {
const parametersObject = Object.fromEntries(parameters.map(({ name, value }) => [name, value]))
const result = await getMcpToolBridge().callTool({
name,
arguments: parametersObject,
})
return result satisfies {
content?: Record<string, unknown>[]
isError?: boolean
structuredContent?: Record<string, unknown>
toolResult?: unknown
}
const args = argsJson ? JSON.parse(argsJson) : {}
return await getMcpToolBridge().callTool({ name, arguments: args })
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
isError: true,
content: [
{
type: 'text',
text: message,
},
],
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
}
}
},
// NOTICE: `arguments` is z.string() (JSON) because z.unknown() produces `{}` (no `type` key)
// and z.record() emits `propertyNames`, both rejected by OpenAI.
parameters: z.object({
name: z.string().describe('The qualified tool name to call. Use format "<serverName>::<toolName>"'),
parameters: z.array(z.object({
name: z.string().describe('The name of the parameter'),
value: z.unknown().describe('The value of the parameter'),
}).strict()).describe('The parameters to pass to the tool'),
name: z.string().describe('Tool name in "<serverName>::<toolName>" format'),
arguments: z.string().describe('JSON object of tool arguments, e.g. {"query":"hello","limit":10}'),
}).strict(),
}),
]