fix(backend): 修复全面审阅发现的核心问题
修复索引首次失败回滚、Vault 扫描边界、Markdown 代码围栏和 UTF-16 Citation 偏移。 收紧 Plugin 权限与 JSON Schema 校验,补齐 Note Move、Task、Attachment 和 Transcript Tool。 接入 OpenAI SSE 与 Ollama JSONL 真流式输出,修正 Provider PATCH 语义并限制运行时内存保留。 新增对应回归测试,后端测试增至 62 项。
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
from uuid import uuid4
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from app.contracts import ModelCapability, ModelInfo, ModelRequest
|
||||
from app.contracts import ModelCapability, ModelEvent, ModelEventType, ModelInfo, ModelRequest
|
||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
||||
|
||||
@@ -86,6 +89,115 @@ class OllamaProvider(TurnStreamingMixin):
|
||||
if isinstance(item, dict) and item.get("name")
|
||||
]
|
||||
|
||||
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
||||
payload = self._chat_payload(request, stream=True)
|
||||
sequence = 0
|
||||
|
||||
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
|
||||
nonlocal sequence
|
||||
item = ModelEvent(
|
||||
event=kind, sequence=sequence, data=data or {},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
sequence += 1
|
||||
return item
|
||||
|
||||
try:
|
||||
async for data in self._stream_json(payload):
|
||||
message = data.get("message") or {}
|
||||
if message.get("thinking"):
|
||||
yield event(ModelEventType.thinking_delta, {"text": message["thinking"]})
|
||||
if message.get("content"):
|
||||
yield event(ModelEventType.text_delta, {"text": message["content"]})
|
||||
for raw_call in message.get("tool_calls") or []:
|
||||
function = raw_call.get("function") or {}
|
||||
call_id = raw_call.get("id") or f"call_{uuid4().hex}"
|
||||
yield event(
|
||||
ModelEventType.tool_call_start,
|
||||
{"tool_call_id": call_id, "name": function.get("name") or ""},
|
||||
)
|
||||
yield event(
|
||||
ModelEventType.tool_call_delta,
|
||||
{
|
||||
"tool_call_id": call_id,
|
||||
"arguments_delta": json.dumps(
|
||||
function.get("arguments") or {}, ensure_ascii=False
|
||||
),
|
||||
},
|
||||
)
|
||||
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
|
||||
if data.get("done"):
|
||||
yield event(
|
||||
ModelEventType.usage,
|
||||
{
|
||||
"input_tokens": int(data.get("prompt_eval_count") or 0),
|
||||
"output_tokens": int(data.get("eval_count") or 0),
|
||||
},
|
||||
)
|
||||
yield event(ModelEventType.done)
|
||||
except ProviderError as exc:
|
||||
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
|
||||
yield event(ModelEventType.done)
|
||||
|
||||
def _chat_payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||
messages = []
|
||||
if request.system:
|
||||
messages.append({"role": "system", "content": request.system})
|
||||
for message in request.messages:
|
||||
item: dict[str, object] = {"role": message.role.value, "content": message.content}
|
||||
if message.tool_calls:
|
||||
item["tool_calls"] = [
|
||||
{"function": {"name": call.name, "arguments": call.arguments}}
|
||||
for call in message.tool_calls
|
||||
]
|
||||
messages.append(item)
|
||||
payload: dict[str, object] = {
|
||||
"model": request.model, "messages": messages, "stream": stream
|
||||
}
|
||||
if request.tools:
|
||||
payload["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
},
|
||||
}
|
||||
for tool in request.tools
|
||||
]
|
||||
return payload
|
||||
|
||||
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout_seconds, transport=self.transport
|
||||
) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{self.base_url}/api/chat", json=payload
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProviderError(
|
||||
"PROVIDER_INVALID_RESPONSE", "Ollama returned invalid JSONL."
|
||||
) from exc
|
||||
if isinstance(data, dict):
|
||||
yield data
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderError("PROVIDER_TIMEOUT", "Ollama request timed out.") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ProviderError(
|
||||
"MODEL_NOT_FOUND" if exc.response.status_code == 404 else "PROVIDER_UNAVAILABLE",
|
||||
f"Ollama returned HTTP {exc.response.status_code}.",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Ollama is unavailable.") from exc
|
||||
|
||||
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
|
||||
try:
|
||||
models = await self.list_models()
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
from app.contracts import MessageRole, ModelCapability, ModelInfo, ModelRequest
|
||||
from app.contracts import (
|
||||
MessageRole,
|
||||
ModelCapability,
|
||||
ModelEvent,
|
||||
ModelEventType,
|
||||
ModelInfo,
|
||||
ModelRequest,
|
||||
)
|
||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||
from app.providers.credentials import CredentialResolver
|
||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
||||
@@ -25,29 +34,7 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
||||
self.transport = transport
|
||||
|
||||
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
||||
payload: dict[str, object] = {
|
||||
"model": request.model,
|
||||
"messages": self._messages(request),
|
||||
"stream": False,
|
||||
}
|
||||
if request.tools:
|
||||
payload["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
},
|
||||
}
|
||||
for tool in request.tools
|
||||
]
|
||||
if request.temperature is not None:
|
||||
payload["temperature"] = request.temperature
|
||||
if request.max_tokens is not None:
|
||||
payload["max_tokens"] = request.max_tokens
|
||||
if request.response_format is not None:
|
||||
payload["response_format"] = request.response_format
|
||||
payload = self._payload(request, stream=False)
|
||||
|
||||
data = await self._request("POST", "/chat/completions", json=payload)
|
||||
try:
|
||||
@@ -73,6 +60,133 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
||||
output_tokens=int(usage.get("completion_tokens") or 0),
|
||||
)
|
||||
|
||||
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"model": request.model,
|
||||
"messages": self._messages(request),
|
||||
"stream": stream,
|
||||
}
|
||||
if request.tools:
|
||||
payload["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
},
|
||||
}
|
||||
for tool in request.tools
|
||||
]
|
||||
if request.temperature is not None:
|
||||
payload["temperature"] = request.temperature
|
||||
if request.max_tokens is not None:
|
||||
payload["max_tokens"] = request.max_tokens
|
||||
if request.response_format is not None:
|
||||
payload["response_format"] = request.response_format
|
||||
|
||||
return payload
|
||||
|
||||
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
||||
sequence = 0
|
||||
open_calls: dict[int, str] = {}
|
||||
|
||||
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
|
||||
nonlocal sequence
|
||||
item = ModelEvent(
|
||||
event=kind,
|
||||
sequence=sequence,
|
||||
data=data or {},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
sequence += 1
|
||||
return item
|
||||
|
||||
try:
|
||||
async for data in self._stream_json(self._payload(request, stream=True)):
|
||||
usage = data.get("usage") or {}
|
||||
if usage:
|
||||
yield event(
|
||||
ModelEventType.usage,
|
||||
{
|
||||
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
||||
"output_tokens": int(usage.get("completion_tokens") or 0),
|
||||
},
|
||||
)
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
choice = choices[0]
|
||||
delta = choice.get("delta") or {}
|
||||
if delta.get("reasoning_content"):
|
||||
yield event(
|
||||
ModelEventType.thinking_delta,
|
||||
{"text": delta["reasoning_content"]},
|
||||
)
|
||||
if delta.get("content"):
|
||||
yield event(ModelEventType.text_delta, {"text": delta["content"]})
|
||||
for raw_call in delta.get("tool_calls") or []:
|
||||
index = int(raw_call.get("index") or 0)
|
||||
function = raw_call.get("function") or {}
|
||||
call_id = raw_call.get("id") or open_calls.get(index) or f"call_{uuid4().hex}"
|
||||
if index not in open_calls:
|
||||
open_calls[index] = call_id
|
||||
yield event(
|
||||
ModelEventType.tool_call_start,
|
||||
{"tool_call_id": call_id, "name": function.get("name") or ""},
|
||||
)
|
||||
if function.get("arguments"):
|
||||
yield event(
|
||||
ModelEventType.tool_call_delta,
|
||||
{
|
||||
"tool_call_id": open_calls[index],
|
||||
"arguments_delta": function["arguments"],
|
||||
},
|
||||
)
|
||||
if choice.get("finish_reason") == "tool_calls":
|
||||
for call_id in open_calls.values():
|
||||
yield event(
|
||||
ModelEventType.tool_call_end, {"tool_call_id": call_id}
|
||||
)
|
||||
open_calls.clear()
|
||||
for call_id in open_calls.values():
|
||||
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
|
||||
yield event(ModelEventType.done)
|
||||
except ProviderError as exc:
|
||||
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
|
||||
yield event(ModelEventType.done)
|
||||
|
||||
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
|
||||
headers = self._headers()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout_seconds, transport=self.transport
|
||||
) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{self.base_url}/chat/completions", headers=headers, json=payload
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
value = line[5:].strip()
|
||||
if not value or value == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
data = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProviderError(
|
||||
"PROVIDER_INVALID_RESPONSE", "Provider returned invalid SSE JSON."
|
||||
) from exc
|
||||
if isinstance(data, dict):
|
||||
yield data
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise self._status_error(exc) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
||||
|
||||
async def list_models(self) -> list[ModelInfo]:
|
||||
data = await self._request("GET", "/models")
|
||||
return [
|
||||
@@ -127,10 +241,7 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
||||
return result
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers = self._headers()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout_seconds, transport=self.transport
|
||||
@@ -143,14 +254,25 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
code = {
|
||||
401: "PROVIDER_AUTH_FAILED",
|
||||
404: "MODEL_NOT_FOUND",
|
||||
429: "PROVIDER_RATE_LIMITED",
|
||||
}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
|
||||
raise ProviderError(code, f"Provider returned HTTP {exc.response.status_code}.") from exc
|
||||
raise self._status_error(exc) from exc
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Provider returned non-object JSON.")
|
||||
return data
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _status_error(exc: httpx.HTTPStatusError) -> ProviderError:
|
||||
code = {
|
||||
401: "PROVIDER_AUTH_FAILED",
|
||||
404: "MODEL_NOT_FOUND",
|
||||
429: "PROVIDER_RATE_LIMITED",
|
||||
}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
|
||||
return ProviderError(code, f"Provider returned HTTP {exc.response.status_code}.")
|
||||
|
||||
Reference in New Issue
Block a user