fix: 修复api 适配问题

This commit is contained in:
ViperEkura 2026-05-04 21:20:36 +08:00
parent e070dca10e
commit c332380080
16 changed files with 464 additions and 73 deletions

View File

@ -0,0 +1,48 @@
<template>
<div class="pagination">
<button class="page-btn" :disabled="page <= 1" @click="$emit('page-change', page - 1)"></button>
<template v-for="p in visiblePages">
<span v-if="p === '...'" :key="p + '-ellipsis'" class="page-ellipsis"></span>
<button v-else :key="p" class="page-btn" :class="{ active: p === page }" @click="$emit('page-change', p)">{{ p }}</button>
</template>
<button class="page-btn" :disabled="page >= totalPages" @click="$emit('page-change', page + 1)"></button>
<span class="page-info"> {{ total }} </span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
total: { type: Number, default: 0 }
})
defineEmits(['page-change'])
const visiblePages = computed(() => {
const tp = props.totalPages
const cur = props.page
if (tp <= 7) return Array.from({ length: tp }, (_, i) => i + 1)
const pages = []
if (cur <= 4) {
pages.push(1, 2, 3, 4, 5, '...', tp)
} else if (cur >= tp - 3) {
pages.push(1, '...', tp - 4, tp - 3, tp - 2, tp - 1, tp)
} else {
pages.push(1, '...', cur - 1, cur, cur + 1, '...', tp)
}
return pages
})
</script>
<style scoped>
.pagination { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; }
.page-btn { padding: 0.25rem 0.5rem; border: 1px solid #d1d5db; background: white; border-radius: 0.25rem; cursor: pointer; font-size: 0.875rem; }
.page-btn:hover:not(:disabled) { background: #f3f4f6; }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
.page-ellipsis { padding: 0.25rem 0.25rem; color: #6b7280; }
.page-info { font-size: 0.875rem; color: #6b7280; margin-left: 0.5rem; }
</style>

View File

@ -142,7 +142,8 @@ const allItems = computed(() => {
displayContent = JSON.stringify(parsed, null, 2)
}
} catch (e) {
// JSON
// JSON
console.warn('Failed to parse tool result JSON:', e)
}
match.resultSummary = displayContent.slice(0, 200)

View File

@ -149,7 +149,8 @@ class StreamManager {
const data = JSON.parse(line.slice(6))
this._handleEvent(conversationId, currentEvent, data, streamStore, null)
} catch (e) {
// 忽略解析错误
console.error('SSE parse error in _processLines:', e, 'line:', line)
streamStore.errorStream(conversationId, `Parse error: ${e.message}`)
}
}
}

View File

@ -48,7 +48,16 @@ export const useStreamStore = defineStore('stream', () => {
const idx = state.process_steps.findIndex(s => s.id === step.id)
if (idx >= 0) {
state.process_steps[idx] = step
// 对于 thinking/text 步骤,后端发送的是增量内容(基于 offset
// 需要追加到已有内容上,而不是替换
if (step.type === 'thinking' || step.type === 'text') {
const existing = state.process_steps[idx]
existing.content = (existing.content || '') + (step.content || '')
// 触发响应式更新
state.process_steps[idx] = { ...existing }
} else {
state.process_steps[idx] = step
}
} else {
state.process_steps.push(step)
}

View File

@ -48,11 +48,12 @@
</div>
</div>
<div v-if="totalPages > 1" class="sidebar-pagination">
<button @click="page--; fetchData()" :disabled="page === 1" class="btn-page"></button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="page++; fetchData()" :disabled="page >= totalPages" class="btn-page"></button>
</div>
<Pagination
v-if="totalPages > 1"
:page="page"
:total-pages="totalPages"
@page-change="handlePageChange"
/>
</aside>
<!-- 右侧内容区 - 对话界面 -->
@ -170,6 +171,7 @@ import { formatDate } from '../utils/useFormatters.js'
import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.vue'
import MessageBubble from '../components/MessageBubble.vue'
import Pagination from '../components/Pagination.vue'
const {
list,
@ -277,6 +279,12 @@ const handleRegenerateMessage = async (msgId) => {
}
//
const handlePageChange = (newPage) => {
page.value = newPage
fetchData()
}
const onProviderChange = () => {
const p = providers.value.find(p => p.id === form.value.provider_id)
if (p) form.value.model = p.default_model || ''

View File

@ -155,6 +155,7 @@
<div class="section-title">
<span class="section-icon">🔌</span>
<span class="section-text">LLM Provider</span>
<span class="section-count">{{ providers.length }}</span>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
@ -163,16 +164,17 @@
<table class="settings-table">
<thead>
<tr>
<th>名称</th>
<th>名称 / 类型</th>
<th>API / 模型</th>
<th class="switch-col">启用</th>
<th class="ops-col">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in providers" :key="p.id">
<tr v-for="p in paginatedProviders" :key="p.id">
<td class="name-col">
<div class="provider-name">{{ p.name }}</div>
<div class="provider-type">{{ p.provider_type }}</div>
<div class="provider-badges">
<span v-if="p.is_default" class="badge badge-default">默认</span>
<span v-if="p.enabled" class="badge badge-enabled">启用</span>
@ -180,9 +182,9 @@
</div>
</td>
<td class="info-col">
<div class="info-item">{{ p.base_url }}</div>
<div class="info-item sub">模型: {{ p.default_model }}</div>
<div class="info-item sub">最大Tokens: {{ p.max_tokens || 8192 }}</div>
<div class="info-item" :title="p.base_url">{{ p.base_url }}</div>
<div class="info-item sub">模型: <code>{{ p.default_model }}</code></div>
<div class="info-item sub">最大 Tokens: {{ p.max_tokens || 8192 }}</div>
</td>
<td class="switch-col">
<label class="switch" @click.prevent="toggleEnabled(p)">
@ -208,7 +210,15 @@
<tfoot>
<tr>
<td colspan="4" class="table-footer">
<button @click="showModal = true" class="btn-primary">
<Pagination
v-if="providerTotalPages > 1"
:page="providerPage"
:total-pages="providerTotalPages"
:total="providers.length"
@page-change="handleProviderPageChange"
class="pagination-inline"
/>
<button @click="showModal = true; editing = null" class="btn-primary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#plus-icon"/></svg>
添加 Provider
</button>
@ -240,7 +250,7 @@
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<tr v-for="u in paginatedUsers" :key="u.id">
<td class="name-col">{{ u.username }}</td>
<td class="info-col">{{ u.email || '-' }}</td>
<td>{{ u.role }}</td>
@ -257,6 +267,19 @@
</td>
</tr>
</tbody>
<tfoot v-if="userTotalPages > 1">
<tr>
<td colspan="5" class="table-footer">
<Pagination
:page="userPage"
:total-pages="userTotalPages"
:total="users.length"
@page-change="handleUserPageChange"
class="pagination-inline"
/>
</td>
</tr>
</tfoot>
</table>
</div>
<div v-else class="empty-card">暂无用户</div>
@ -308,23 +331,55 @@
<!-- 添加/编辑 Provider 模态框 -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal provider-modal">
<h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2>
<div class="form-group"><label>名称</label><input v-model="form.name" placeholder="如: 我的 DeepSeek" /></div>
<div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/chat/completions" /></div>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" /></div>
<div class="form-group">
<label>名称 <span class="required">*</span></label>
<input v-model="form.name" placeholder="如: 我的 DeepSeek" />
</div>
<div class="form-group">
<label>模型名称</label>
<label>Provider 类型 <span class="required">*</span></label>
<select v-model="form.provider_type" class="form-select">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="deepseek">DeepSeek</option>
<option value="glm">GLM (智谱)</option>
<option value="openai-compatible">OpenAI 兼容</option>
</select>
<span class="hint">不同类型的 API 格式和认证方式不同</span>
</div>
<div class="form-group">
<label>Base URL <span class="required">*</span></label>
<input v-model="form.base_url" placeholder="https://api.deepseek.com/v1" />
</div>
<div class="form-group">
<label>API Key</label>
<input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" />
<span v-if="editing" class="hint">留空则不修改已有 Key</span>
</div>
<div class="form-group">
<label>模型名称 <span class="required">*</span></label>
<input v-model="form.default_model" placeholder="deepseek-chat / gpt-4" required />
</div>
<div class="form-group">
<label>最大 Tokens</label>
<input v-model.number="form.max_tokens" type="number" placeholder="8192" min="1" />
<span class="hint">单次回复最大 token 默认 8192</span>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input v-model="form.is_default" type="checkbox" />
<span>设为默认 Provider</span>
</label>
</div>
<div v-if="formError" class="error">{{ formError }}</div>
<div class="modal-actions">
@ -343,11 +398,12 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { providersAPI } from '../utils/api.js'
import { useAuth } from '../utils/useAuth.js'
import { authAPI } from '../utils/api.js'
import { useRouter } from 'vue-router'
import Pagination from '../components/Pagination.vue'
const router = useRouter()
const { logout, user: currentUser } = useAuth()
@ -465,8 +521,32 @@ const testing = ref(null)
const testResult = ref(null)
const formError = ref('')
// Provider
const providerPage = ref(1)
const providerPageSize = 10
const providerTotalPages = computed(() => Math.max(1, Math.ceil(providers.value.length / providerPageSize)))
const paginatedProviders = computed(() => {
const start = (providerPage.value - 1) * providerPageSize
return providers.value.slice(start, start + providerPageSize)
})
const handleProviderPageChange = (newPage) => {
providerPage.value = newPage
}
//
const userPage = ref(1)
const userPageSize = 10
const userTotalPages = computed(() => Math.max(1, Math.ceil(users.value.length / userPageSize)))
const paginatedUsers = computed(() => {
const start = (userPage.value - 1) * userPageSize
return users.value.slice(start, start + userPageSize)
})
const handleUserPageChange = (newPage) => {
userPage.value = newPage
}
const form = ref({
name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192
name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', max_tokens: 8192, is_default: false
})
const fetchProviders = async () => {
@ -504,7 +584,7 @@ const fetchSettings = async () => {
const closeModal = () => {
showModal.value = false
editing.value = null
form.value = { name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192 }
form.value = { name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', max_tokens: 8192, is_default: false }
formError.value = ''
}
@ -515,10 +595,12 @@ const editProvider = async (p) => {
if (res.success && res.data) {
form.value = {
name: res.data.name,
provider_type: res.data.provider_type || 'openai',
base_url: res.data.base_url,
api_key: '', //
default_model: res.data.default_model,
max_tokens: res.data.max_tokens || 8192
max_tokens: res.data.max_tokens || 8192,
is_default: res.data.is_default || false
}
}
} catch (e) {
@ -663,6 +745,19 @@ onMounted(() => {
.section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; }
.section-icon { font-size: 1rem; }
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
.section-count {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent-primary);
color: white;
font-size: 0.7rem;
font-weight: 600;
margin-left: 0.35rem;
}
/* 内联输入框 */
.inline-select, .inline-input { padding: 0.5rem 0.75rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; }
@ -678,6 +773,12 @@ onMounted(() => {
/* 列宽 */
.name-col { width: 15%; min-width: 120px; }
.provider-type {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: 0.2rem;
font-family: monospace;
}
.info-col { width: 60%; min-width: 200px; }
.switch-col { text-align: center; width: 80px; }
.ops-col { width: 15%; min-width: 180px; text-align: center; }
@ -692,6 +793,7 @@ onMounted(() => {
/* 表格底部 */
.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
.pagination-inline { display: inline-flex; margin-right: 1rem; border-top: none; padding: 0; background: transparent; }
/* Provider 单元格 */
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
@ -731,6 +833,7 @@ onMounted(() => {
/* 模态框 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border-radius: 16px; padding: 1.25rem; width: 100%; max-width: 400px; }
.provider-modal { max-width: 480px; }
.modal h2 { margin: 0 0 1.25rem; color: var(--text-primary); font-size: 1.1rem; }
.result-modal { text-align: center; }
@ -745,9 +848,37 @@ onMounted(() => {
.result-modal .result-json { background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; padding: 0.75rem; margin: 0.75rem 0; max-height: 150px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
.form-group { margin-bottom: 1rem; }
.form-group .required { color: var(--danger-color); }
.form-select {
width: 100%;
padding: 0.65rem;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--bg-input);
box-sizing: border-box;
color: var(--text-primary);
font-size: 0.9rem;
}
.checkbox-group { margin-top: 0.5rem; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
cursor: pointer;
}
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
.form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; }
.info-item code {
background: var(--bg-code);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.8rem;
}
.optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; }
.error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; }

View File

@ -150,7 +150,8 @@ async def stream_message(
user_id=current_user.id,
username=current_user.username,
workspace=workspace,
user_permission_level=current_user.permission_level
user_permission_level=current_user.permission_level,
skip_user_message=True # User message already saved in DB above
):
# Chat service returns raw SSE strings (including done event)
yield sse_str

View File

@ -6,13 +6,29 @@ from pydantic import BaseModel
from luxx.database import get_db
from luxx.models import User
from luxx.routes.auth import get_current_user
from luxx.tools.core import registry
from luxx.tools.core import registry, CommandPermission
from luxx.utils.helpers import success_response
router = APIRouter(prefix="/tools", tags=["Tools"])
def _tool_to_detail(tool) -> Dict[str, Any]:
"""Convert tool definition to a detailed dict with permission and category info"""
# to_openai_format returns {"type": "function", "function": {name, description, parameters}}
# Flatten the function properties to top level for easier frontend access
base = tool.to_openai_format()
func = base.get("function", {})
return {
"name": func.get("name", tool.name),
"description": func.get("description", tool.description),
"parameters": func.get("parameters", tool.parameters),
"category": tool.category,
"required_permission": tool.required_permission.name,
"required_permission_level": int(tool.required_permission)
}
@router.get("/", response_model=dict)
def list_tools(
category: Optional[str] = None,
@ -23,17 +39,17 @@ def list_tools(
if category:
categorized_tools = [t for t in registry._tools.values() if t.category == category]
tools = [t.to_openai_format() for t in categorized_tools]
tools = [_tool_to_detail(t) for t in categorized_tools]
else:
categorized_tools = list(registry._tools.values())
tools = registry.list_all()
tools = [_tool_to_detail(t) for t in categorized_tools]
categorized = {}
for tool in categorized_tools:
cat = tool.category
if cat not in categorized:
categorized[cat] = []
categorized[cat].append(tool.to_openai_format())
categorized[cat].append(_tool_to_detail(tool))
return success_response(data={
"tools": tools,
@ -53,7 +69,7 @@ def get_tool(
if not tool:
return {"success": False, "message": "Tool not found", "code": 404}
return success_response(data=tool.to_openai_format())
return success_response(data=_tool_to_detail(tool))
@router.post("/{name}/execute", response_model=dict)

View File

@ -2,6 +2,7 @@
This module follows the Single Responsibility Principle.
"""
import json
import uuid
import logging
from typing import List, Dict, AsyncGenerator
@ -22,6 +23,7 @@ class AgenticLoop:
def __init__(self, tool_executor: ToolExecutor):
self.tool_executor = tool_executor
self._reasoning_content = "" # DeepSeek thinking mode - persists across iterations
async def execute(
self,
@ -37,18 +39,42 @@ class AgenticLoop:
) -> AsyncGenerator[str, None]:
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
# Reset reasoning_content for each execution (DeepSeek thinking mode)
self._reasoning_content = ""
for iteration in range(MAX_ITERATIONS):
# Per-iteration reset, keep previous steps and tool results
context.reset(full_reset=False)
# BUG FIX: DeepSeek thinking mode requires reasoning_content to be passed back.
# Without this, DeepSeek returns:
# "The `reasoning_content` in the thinking mode must be passed back to the API."
# We find the last assistant message and add reasoning_content from the previous
# iteration's accumulated thinking content.
if iteration > 0 and self._reasoning_content:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant":
messages[i]["reasoning_content"] = self._reasoning_content
break
async for delta in llm.stream_call(
model=model,
messages=messages,
tools=tools,
tools=tools if iteration == 0 else None, # Only send tools on first iteration
temperature=temperature,
max_tokens=max_tokens,
thinking_enabled=thinking_enabled
):
# Handle error delta - propagate as SSE error event
if delta.has_error():
logger.error(f"[AGENTIC_LOOP] Stream error: {delta.error_msg}")
yield sse_event("error", {"content": delta.error_msg})
return
# Accumulate reasoning_content for DeepSeek thinking mode
if delta.has_thinking():
self._reasoning_content += delta.thinking
events = self._process_delta(delta, context, total_usage)
for event in events:
yield event
@ -88,6 +114,19 @@ class AgenticLoop:
"total_tokens": delta.usage.get("total_tokens", 0)
})
# BUG FIX: Handle DeepSeek reasoning_content (delta.thinking).
# DeepSeek sends reasoning_content in separate deltas from normal content.
# These must be rendered as thinking steps AND accumulated into
# self._reasoning_content for subsequent API calls (line 71-72).
# Without this, DeepSeek returns 400:
# "The `reasoning_content` in the thinking mode must be passed back to the API."
if delta.thinking:
if ctx.current_step_type != StepType.THINKING:
ctx.start_step(StepType.THINKING)
ctx.full_thinking += delta.thinking
events.append(StreamRenderer.render_thinking(ctx))
ctx._thinking_offset = len(ctx.full_thinking)
if delta.content:
result = ctx.process_content(delta.content)
if result["should_emit"]:
@ -120,12 +159,30 @@ class AgenticLoop:
"""Execute tools and add results to messages"""
events = []
# Check if there's an active streaming step that needs to be finalized
# before tool execution (e.g. text step started but not finalized)
if ctx.current_step_id is not None:
ctx.finalize_step()
for event in StreamRenderer.render_tool_calls(ctx):
events.append(event)
tool_results = self.tool_executor.process_tool_calls_parallel(
ctx.tool_calls_list, tool_context or {}
)
# Execute tools and handle any exceptions
try:
tool_results = self.tool_executor.process_tool_calls_parallel(
ctx.tool_calls_list, tool_context or {}
)
except Exception as e:
logger.error(f"[EXECUTE_TOOLS] Tool execution failed: {type(e).__name__}: {e}")
# Generate error results for each tool call
tool_results = []
for tc in ctx.tool_calls_list:
tool_results.append({
"tool_call_id": tc.get("id", ""),
"role": "tool",
"name": tc.get("function", {}).get("name", "unknown"),
"content": json.dumps({"success": False, "error": f"Tool execution failed: {type(e).__name__}: {str(e)}"})
})
# Build mapping from LLM tool_call_id to step id
# Use index-based matching as fallback when id is empty
@ -146,7 +203,7 @@ class AgenticLoop:
logger.debug(f"[EXECUTE_TOOLS] Matched by id: tc_id={tc_id} -> step_id={ref_id}")
break
# Fallback: use index-based matching
# Fallback: use index-based matching (most reliable for parallel execution)
if ref_id is None and i < len(tool_call_steps):
ref_id = tool_call_steps[i].id
logger.debug(f"[EXECUTE_TOOLS] Matched by index: i={i} -> step_id={ref_id}")
@ -154,16 +211,31 @@ class AgenticLoop:
# Last resort: generate a step id
if ref_id is None:
ref_id = f"step-{len(ctx.all_steps) - len(tool_results) + i}"
logger.debug(f"[EXECUTE_TOOLS] Generated ref_id: {ref_id}")
logger.warning(f"[EXECUTE_TOOLS] Could not match tool result, generated ref_id: {ref_id}")
_, event = StreamRenderer.render_tool_result(ctx, tr, ref_id)
events.append(event)
messages.append({
# When tool_calls exist, content should be null (OpenAI API spec)
assistant_msg = {
"role": "assistant",
"content": ctx.full_content or "",
"tool_calls": ctx.tool_calls_list
})
}
# When tool_calls exist, content must be null (don't include text before tool calls)
if not ctx.tool_calls_list and ctx.full_content and not ctx.full_content.isspace():
assistant_msg["content"] = ctx.full_content
# If tool_calls exist, omit content field entirely (or set to null)
# BUG FIX: Include reasoning_content in assistant message for DeepSeek.
# DeepSeek's thinking mode REQUIRES reasoning_content to be echoed back
# to the API in every subsequent request. Without this, the API returns:
# HTTP 400: "The `reasoning_content` in the thinking mode must be
# passed back to the API."
# This field is accumulated from delta.thinking in the stream loop above.
if self._reasoning_content:
assistant_msg["reasoning_content"] = self._reasoning_content
messages.append(assistant_msg)
messages.extend(ctx.all_tool_results[-len(tool_results):])
return events

View File

@ -187,16 +187,19 @@ class ChatService:
user_id: int = None,
username: str = None,
workspace: str = None,
user_permission_level: int = 1
user_permission_level: int = 1,
skip_user_message: bool = False # Skip adding user message if already in DB
) -> AsyncGenerator[str, None]:
"""Streaming response with Agentic Loop."""
try:
# Build initial messages
messages = self.build_messages(conversation)
messages.append({
"role": "user",
"content": json.dumps({"text": user_message, "attachments": []})
})
# Only add user message if not already in database
if not skip_user_message:
messages.append({
"role": "user",
"content": json.dumps({"text": user_message, "attachments": []})
})
# Get tools and LLM client via factory
tools = self._get_tools(enabled_tools)

View File

@ -35,6 +35,15 @@ class OpenAIAdapter(ProviderAdapter):
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
# DeepSeek-specific parameters
if "reasoning_effort" in kwargs:
body["reasoning_effort"] = kwargs["reasoning_effort"]
if "thinking_enabled" in kwargs and kwargs["thinking_enabled"]:
body["thinking"] = {"type": "enabled"}
if "response_format" in kwargs:
body["response_format"] = kwargs["response_format"]
return body, headers
def reset(self):
@ -79,9 +88,23 @@ class OpenAIAdapter(ProviderAdapter):
finish_reason = choice.get("finish_reason")
content = delta.get("content", "")
# BUG FIX: Extract DeepSeek reasoning_content (thinking mode).
# DeepSeek sends reasoning_content alongside normal content in stream deltas.
# This must be yielded as a separate ParsedDelta with only the "thinking" field
# set, so the agentic loop can:
# 1. Render it as a thinking step in the UI
# 2. Accumulate it to echo back in subsequent API calls
# Without this, DeepSeek returns 400:
# "The `reasoning_content` in the thinking mode must be passed back to the API."
reasoning_content = delta.get("reasoning_content", "")
# MiniMax may send tool_calls as array in delta
tool_calls = delta.get("tool_calls", [])
# Yield thinking (includes DeepSeek reasoning_content)
if reasoning_content:
yield ParsedDelta(thinking=reasoning_content)
# Yield content if present
if content:
yield ParsedDelta(content=content)
@ -90,8 +113,10 @@ class OpenAIAdapter(ProviderAdapter):
for tc in tool_calls:
yield ParsedDelta(tool_call=tc)
# Set is_complete for final chunks (DeepSeek may return null, "length", "content_filter")
if finish_reason and finish_reason not in (None, ""):
# Only set is_complete for actual completion ("stop").
# DeepSeek sends "tool_calls" when it wants to call tools (stream continues with tool_call deltas).
# "length" / "content_filter" mean max_tokens hit - treat as incomplete, let loop handle it.
if finish_reason == "stop":
yield ParsedDelta(is_complete=True)
def parse_response(self, data: Dict) -> Dict:

View File

@ -22,6 +22,7 @@ Usage:
Extending Providers:
LLMClient.register_adapter("my_provider", MyAdapter)
"""
import json
import logging
import traceback
from typing import Dict, List, Any, Optional, AsyncGenerator, Type
@ -284,6 +285,36 @@ class LLMClient:
logger.error(f"Sync call error: {e}\n{traceback.format_exc()}")
raise
@staticmethod
def _parse_error(response: httpx.Response) -> str:
"""Extract error message from an API error response.
Handles various formats: JSON with "error.message", plain text, empty body.
This is a static method so it works both with an open stream (aread)
and a closed response (text fallback).
"""
error_body = ""
try:
error_body_bytes = response.read()
if error_body_bytes:
error_body = error_body_bytes.decode('utf-8', errors='replace')
except Exception:
try:
error_body = response.text
except Exception:
pass
if error_body:
try:
error_json = json.loads(error_body)
detail = error_json.get("error", {}).get("message", "") or str(error_json)
except json.JSONDecodeError:
detail = error_body
else:
detail = f"HTTP {response.status_code} (no body)"
return detail[:500]
async def stream_call(
self,
model: str,
@ -316,7 +347,6 @@ class LLMClient:
endpoint = self.build_endpoint()
logger.info(f"Stream call to {endpoint} with model {model}")
try:
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream(
@ -326,7 +356,16 @@ class LLMClient:
json=body
) as response:
logger.info(f"Response status: {response.status_code}")
response.raise_for_status()
# BUG FIX: Read error body BEFORE raising or closing the stream.
# httpx's client.stream() __aexit__ closes the response stream
# when an exception occurs, making e.response.aread() return 0 bytes
# in the except block below. Reading here (stream still open) fixes this.
if response.status_code >= 400:
error_msg = self._parse_error(response)
logger.error(f"HTTP {response.status_code}: {error_msg}")
yield ParsedDelta(error_msg=error_msg)
return
async for line in response.aiter_lines():
line = line.strip()
@ -351,23 +390,22 @@ class LLMClient:
# Pass clean data to adapter (OpenAIAdapter also handles stripping,
# but AnthropicAdapter and others need clean JSON input)
async for delta in self.adapter.parse_stream_chunk(event_data):
if delta.content or delta.has_tool_call() or delta.is_complete or delta.usage:
# BUG FIX: Include has_thinking() in filter condition.
# DeepSeek sends reasoning_content as separate deltas with only
# the "thinking" field populated. Without has_thinking() check,
# these deltas were silently dropped, preventing reasoning_content
# accumulation and leading to "must be passed back to the API" error.
if delta.content or delta.has_thinking() or delta.has_tool_call() or delta.is_complete or delta.usage:
yield delta
except httpx.HTTPStatusError as e:
status_code = e.response.status_code if e.response else "?"
error_body = ""
if e.response:
try:
await e.response.aread()
error_body = e.response.text
except Exception:
pass
logger.error(f"HTTP error: {status_code} - {error_body}")
yield ParsedDelta()
# Fallback: httpx.HTTPStatusError with closed stream
error_msg = self._parse_error(e.response) if e.response else f"HTTP error"
logger.error(f"HTTP error (fallback): {error_msg}")
yield ParsedDelta(error_msg=error_msg)
except Exception as e:
logger.error(f"Stream error: {type(e).__name__}: {e}\n{traceback.format_exc()}")
yield ParsedDelta()
logger.error(f"Stream call failed: {type(e).__name__}: {e}")
yield ParsedDelta(error_msg=f"{type(e).__name__}: {str(e)}")
# Convenience function

View File

@ -41,11 +41,12 @@ class Step:
class ParsedDelta:
"""LLM streaming response delta"""
content: str = ""
thinking: str = ""
thinking: str = "" # Includes DeepSeek reasoning_content
text: str = ""
tool_call: Optional[Dict] = None
usage: Dict[str, int] = field(default_factory=dict)
is_complete: bool = False
error_msg: Optional[str] = None
def has_thinking(self) -> bool:
return bool(self.thinking)
@ -58,3 +59,6 @@ class ParsedDelta:
def has_content(self) -> bool:
return bool(self.content) or self.has_thinking() or self.has_text() or self.has_tool_call()
def has_error(self) -> bool:
return self.error_msg is not None

View File

@ -91,6 +91,9 @@ class StreamState:
# Track content offset per step to avoid duplication across steps
self._text_offset = 0
self._thinking_offset = 0
# Track step start offset for finalize_step to compute complete step content
self._step_start_text_offset = 0
self._step_start_thinking_offset = 0
def process_content(self, content: str) -> Dict:
"""Process raw content, handling thinking tags."""
@ -172,8 +175,10 @@ class StreamState:
# Record content offset so this step only includes content added during it
if step_type == StepType.TEXT:
self._text_offset = len(self.full_content)
self._step_start_text_offset = len(self.full_content)
elif step_type == StepType.THINKING:
self._thinking_offset = len(self.full_thinking)
self._step_start_thinking_offset = len(self.full_thinking)
self.step_index += 1
return self.current_step_id
@ -190,9 +195,9 @@ class StreamState:
self.current_step_type = None
return
if self.current_step_type == StepType.TEXT:
content = self.full_content[self._text_offset:]
content = self.full_content[self._step_start_text_offset:]
elif self.current_step_type == StepType.THINKING:
content = self.full_thinking[self._thinking_offset:]
content = self.full_thinking[self._step_start_thinking_offset:]
else:
content = ""
step = Step(
@ -211,15 +216,22 @@ class StreamState:
"""Accumulate tool call delta"""
idx = tc_delta.get("index", 0)
if idx >= len(self.tool_calls_list):
# BUG FIX: DeepSeek may omit "type" in tool_call deltas.
# Without defaulting to "function", the tool call would have
# type=None, causing JSON serialization issues or API rejections.
tc_type = tc_delta.get("type", "function")
self.tool_calls_list.append({
"id": tc_delta.get("id", ""),
"type": "function",
"type": tc_type,
"function": {"name": "", "arguments": ""}
})
else:
# Update id if provided (LLM may send id in a later delta)
if tc_delta.get("id"):
self.tool_calls_list[idx]["id"] = tc_delta["id"]
# Ensure type field is always present
if "type" in tc_delta:
self.tool_calls_list[idx]["type"] = tc_delta["type"]
func = tc_delta.get("function", {})
if func.get("name"):
self.tool_calls_list[idx]["function"]["name"] += func["name"]

View File

@ -2,7 +2,9 @@
from dataclasses import dataclass, field
from typing import Callable, Any, Dict, List, Optional
from enum import IntEnum
import logging
logger = logging.getLogger(__name__)
class CommandPermission(IntEnum):
"""Command permission level - higher value means more permissions"""
@ -98,8 +100,10 @@ class ToolRegistry:
def execute(self, name: str, arguments: dict, context: ToolContext = None) -> Dict[str, Any]:
"""Execute tool with optional context and automatic permission check"""
tool = self.get(name)
if not tool:
logger.error(f"[REGISTRY] Tool '{name}' not found")
return {"success": False, "error": f"Tool '{name}' not found"}
# Automatic permission check (transparent to tool function)
@ -109,22 +113,27 @@ class ToolRegistry:
if isinstance(user_level, int):
user_level = CommandPermission(user_level)
if user_level < tool.required_permission:
logger.warning(f"[REGISTRY] Permission denied for tool '{name}': requires {tool.required_permission.name}, user has {user_level.name}")
return {
"success": False,
"error": f"Permission denied: requires {tool.required_permission.name}, you have {user_level.name}"
}
logger.info(f"[REGISTRY] Executing tool '{name}' with args: {arguments}")
try:
# Pass context to handler if it accepts it
if context is not None:
result = tool.handler(arguments, context=context)
else:
result = tool.handler(arguments)
logger.info(f"[REGISTRY] Tool '{name}' executed successfully")
if isinstance(result, ToolResult):
return result.to_dict()
return result
except Exception as e:
return {"success": False, "error": str(e)}
import traceback
logger.error(f"[REGISTRY] Tool '{name}' execution failed: {type(e).__name__}: {str(e)}\n{traceback.format_exc()}")
return {"success": False, "error": f"{type(e).__name__}: {str(e)}"}
def clear(self) -> None:
"""Clear all tools"""

View File

@ -7,11 +7,12 @@ This module follows the Single Responsibility Principle:
"""
import json
import time
import logging
from typing import List, Dict, Any, Optional
from threading import Lock
from luxx.tools.core import registry, ToolContext
logger = logging.getLogger(__name__)
class CacheManager:
"""Manages tool result caching"""
@ -165,6 +166,8 @@ class ToolExecutor:
not in completion order. This ensures proper matching between tool_call
and tool_result steps in the frontend.
"""
if len(tool_calls) <= 1:
return self.process_tool_calls(tool_calls, context)
@ -201,16 +204,27 @@ class ToolExecutor:
# Wait for all futures and store results at correct indices
for future in futures_with_index:
idx, call_id, name, args, cache_key = futures_with_index[future]
result = future.result()
self.cache.set(cache_key, result)
self.history.record(name, args, result)
results[idx] = self._create_tool_result(call_id, name, result)
try:
result = future.result()
self.cache.set(cache_key, result)
self.history.record(name, args, result)
results[idx] = self._create_tool_result(call_id, name, result)
except Exception as e:
logger.error(f"[EXECUTOR] Tool '{name}' execution failed: {type(e).__name__}: {e}")
# Create error result
error_result = {"success": False, "error": f"{type(e).__name__}: {str(e)}"}
self.history.record(name, args, error_result)
results[idx] = self._create_tool_result(call_id, name, error_result)
# Filter out None values (shouldn't happen, but safety check)
return [r for r in results if r is not None]
except ImportError:
return self.process_tool_calls(tool_calls, context)
except Exception as e:
logger.error(f"[EXECUTOR] Parallel execution failed: {type(e).__name__}: {e}")
# Fallback to sequential execution
return self.process_tool_calls(tool_calls, context)
def _build_tool_context(self, context: Dict[str, Any]) -> ToolContext:
"""Build ToolContext from context dict"""
@ -232,11 +246,10 @@ class ToolExecutor:
return {}
def _create_tool_result(self, call_id: str, name: str, result: Dict) -> Dict[str, Any]:
"""Create tool result message"""
"""Create tool result message (OpenAI format: only tool_call_id and content)"""
return {
"tool_call_id": call_id,
"role": "tool",
"name": name,
"content": json.dumps(result, ensure_ascii=False)
}