Skip to content

Commit 4175f11

Browse files
authored
Merge pull request #253 from nextcloud/feat/streaming
feat: Implement streaming via TaskProcessing
2 parents 0ec2cb6 + b6edb48 commit 4175f11

15 files changed

Lines changed: 375 additions & 48 deletions

.github/workflows/integration_test.yml

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,21 @@ jobs:
123123
pipx install poetry
124124
poetry install
125125
126+
- name: Cache llm2 models
127+
uses: actions/cache/restore@v5
128+
id: cache-llm2-models-restore
129+
env:
130+
cache-name: cache-llm2-models
131+
with:
132+
path: llm2-persistent_storage/
133+
key: ${{ runner.os }}-llm2-models-${{ env.cache-name }}-${{ hashFiles('llm2/lib/main.py') }}
134+
126135
- name: Install and init backend
127136
working-directory: ${{ env.APP_NAME }}/lib
128137
env:
129138
APP_VERSION: ${{ fromJson(steps.appinfo.outputs.result).version }}
130139
run: |
131-
poetry run python3 main.py > ../backend_logs 2>&1 &
140+
APP_PERSISTENT_STORAGE="$(pwd)/../../llm2-persistent_storage/" poetry run python3 main.py > ../backend_logs 2>&1 &
132141
133142
- name: Register backend
134143
run: |
@@ -156,6 +165,43 @@ jobs:
156165
curl -u "$CREDS" -H "oCS-APIRequest: true" http://localhost:8080/ocs/v2.php/taskprocessing/task/$TASK_ID?format=json
157166
[ "$TASK_STATUS" == '"STATUS_SUCCESSFUL"' ]
158167
168+
- name: Cache llm2 models
169+
uses: actions/cache/save@v5
170+
env:
171+
cache-name: cache-llm2-models
172+
with:
173+
path: llm2-persistent_storage/
174+
key: ${{ steps.cache-llm2-models-restore.outputs.cache-primary-key }}
175+
176+
- name: Run streaming task
177+
if: matrix.server-versions == 'master'
178+
env:
179+
CREDS: "admin:password"
180+
run: |
181+
set -x
182+
TASK=$(curl -X POST -u "$CREDS" -H "oCS-APIRequest: true" -H "Content-type: application/json" http://localhost:8080/ocs/v2.php/taskprocessing/schedule?format=json --data-raw '{"input": {"input": "Count from 1 to 20 in words"},"type":"core:text2text", "appId": "test", "customId": "", "preferStreaming": true}')
183+
echo $TASK
184+
TASK_ID=$(echo $TASK | jq '.ocs.data.task.id')
185+
NEXT_WAIT_TIME=0
186+
TASK_STATUS='"STATUS_SCHEDULED"'
187+
STREAMING_UPDATES=0
188+
until [ $NEXT_WAIT_TIME -eq 35 ] || [ "$TASK_STATUS" == '"STATUS_SUCCESSFUL"' ] || [ "$TASK_STATUS" == '"STATUS_FAILED"' ]; do
189+
TASK=$(curl -u "$CREDS" -H "oCS-APIRequest: true" http://localhost:8080/ocs/v2.php/taskprocessing/task/$TASK_ID?format=json)
190+
echo $TASK
191+
TASK_STATUS=$(echo $TASK | jq '.ocs.data.task.status')
192+
echo $TASK_STATUS
193+
TASK_OUTPUT=$(echo $TASK | jq -r '.ocs.data.task.output.output // ""')
194+
if [ -n "$TASK_OUTPUT" ] && [ "$TASK_STATUS" != '"STATUS_SUCCESSFUL"' ] && [ "$TASK_STATUS" != '"STATUS_FAILED"' ]; then
195+
STREAMING_UPDATES=$((STREAMING_UPDATES+1))
196+
echo "Streaming update detected (count: $STREAMING_UPDATES)"
197+
fi
198+
sleep $(( NEXT_WAIT_TIME++ ))
199+
done
200+
echo "Final status: $TASK_STATUS"
201+
echo "Total streaming updates detected: $STREAMING_UPDATES"
202+
[ "$TASK_STATUS" == '"STATUS_SUCCESSFUL"' ]
203+
[ $STREAMING_UPDATES -gt 0 ]
204+
159205
- name: Show logs
160206
if: always()
161207
run: |

default_config/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"Qwen3.5-9B-Q4_K_M": {
6262
"prompt": "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n{system_prompt}<|eot_id|><|start_header_id|>user<|end_header_id|>\n{user_prompt}<|eot_id|>\n<|start_header_id|>assistant<|end_header_id|>\n",
6363
"loader_config": {
64-
"n_ctx": 16384,
64+
"n_ctx": 24000,
6565
"max_tokens": 8192,
6666
"stop": ["<|eot_id|>"],
6767
"temperature": 0.7

lib/change_tone.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from langchain_core.messages import SystemMessage, HumanMessage
1111
from langchain_core.runnables import Runnable
1212

13+
from streaming import StreamContext, run_runnable_with_streaming
14+
1315
class ChangeToneProcessor:
1416

1517
runnable: Runnable
@@ -33,10 +35,10 @@ class ChangeToneProcessor:
3335
def __init__(self, runnable: Runnable):
3436
self.runnable = runnable
3537

36-
def __call__(self, input_data: dict) -> dict[str, Any]:
38+
def __call__(self, input_data: dict, context: StreamContext | None = None) -> dict[str, Any]:
3739
"""Process a single input"""
3840
messages = [
3941
SystemMessage(content=self.system_prompt),
4042
HumanMessage(content=self.user_prompt.format_prompt(text=input_data['input'], tone=input_data['tone']).to_string())
4143
]
42-
return {'output':self.runnable.invoke(messages).content }
44+
return {'output': run_runnable_with_streaming(self.runnable, messages, context)}

lib/chat.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
"""A chat chain
44
"""
55
import json
6-
from typing import Any, Optional
6+
from typing import Any
77

8-
from langchain.callbacks.manager import CallbackManagerForChainRun
9-
from langchain.chains.base import Chain
10-
from langchain_community.chat_models import ChatLlamaCpp
118
from langchain_core.runnables import Runnable
129

10+
from streaming import StreamContext, run_runnable_with_streaming
11+
1312

1413
class ChatProcessor:
1514
"""
@@ -24,10 +23,19 @@ def __init__(self, runner: Runnable):
2423
def __call__(
2524
self,
2625
inputs: dict[str, Any],
26+
context: StreamContext | None = None,
2727
) -> dict[str, str]:
2828
system_prompt = inputs['system_prompt']
2929
if inputs.get('memories'):
3030
system_prompt += "\n\nYou can remember things from other conversations with the user. If they are relevant, take into account the following memories: \n" + "\n\n".join(inputs['memories']) + "\n\n"
31-
return {'output': self.runnable.invoke(
32-
[('human', system_prompt)] + [(message['role'], message['content']) for message in [json.loads(message) for message in inputs['history']]] + [('human', inputs['input'])]
33-
).content}
31+
messages = [('human', system_prompt)] + [
32+
(message['role'], message['content'])
33+
for message in [json.loads(message) for message in inputs['history']]
34+
] + [('human', inputs['input'])]
35+
return {
36+
'output': run_runnable_with_streaming(
37+
self.runnable,
38+
messages,
39+
context,
40+
)
41+
}

lib/chatwithtools.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,34 @@
33
"""A chat chain
44
"""
55
import json
6+
import hashlib
67
import pprint
78
import re
8-
from random import randint
99
from typing import Any
1010

1111
from langchain_community.chat_models import ChatLlamaCpp
12-
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
12+
from langchain_core.messages import SystemMessage, HumanMessage
1313
from langchain_core.messages.ai import AIMessage
1414

15+
from streaming import StreamContext, run_runnable_with_streaming
16+
1517
def generate_tool_call(tool_call: dict):
1618
content = '<tool_call>'
1719
content += json.dumps({"name": tool_call['name'], "arguments": tool_call['args']})
1820
content += '</tool_call>'
1921
return content
2022

23+
24+
def generate_tool_call_id(tool_call: dict) -> str:
25+
stable_payload = json.dumps(
26+
{
27+
"name": tool_call.get("name"),
28+
"args": tool_call.get("args", tool_call.get("arguments", {})),
29+
},
30+
sort_keys=True,
31+
)
32+
return hashlib.sha1(stable_payload.encode("utf-8")).hexdigest()[:16]
33+
2134
def try_parse_tool_calls(content: str):
2235
"""Try parse the tool calls."""
2336
tool_calls = []
@@ -40,7 +53,9 @@ def try_parse_tool_calls(content: str):
4053
func['args'] = func['arguments']
4154
del func['arguments']
4255
if not 'id' in func:
43-
func['id'] = str(randint(1, 10000000000))
56+
func['id'] = generate_tool_call_id(func)
57+
if 'type' not in func:
58+
func['type'] = 'tool_call'
4459
found = True
4560
except json.JSONDecodeError as e:
4661
print(f"Failed to parse tool calls: the content is {m.group(1)} and {e}")
@@ -66,7 +81,9 @@ def try_parse_tool_calls(content: str):
6681
func['args'] = func['arguments']
6782
del func['arguments']
6883
if not 'id' in func:
69-
func['id'] = str(randint(1, 10000000000))
84+
func['id'] = generate_tool_call_id(func)
85+
if 'type' not in func:
86+
func['type'] = 'tool_call'
7087
except json.JSONDecodeError as e:
7188
print(f"Failed to parse tool calls: the content is {m.group(1)} and {e}")
7289
pass
@@ -79,6 +96,31 @@ def try_parse_tool_calls(content: str):
7996
return {"role": "assistant", "content": c, "tool_calls": tool_calls}
8097
return {"role": "assistant", "content": re.sub(r"<\|im_end\|>$", "", content)}
8198

99+
100+
def strip_tool_calls_for_streaming(content: str) -> str:
101+
sanitized = re.sub(r"<tool_call>.*?</tool_call>", "", content, flags=re.DOTALL)
102+
sanitized = re.sub(r"```tool_call\n.*?\n```", "", sanitized, flags=re.DOTALL)
103+
104+
partial_markers = [index for index in (sanitized.find("<tool"), sanitized.find("```tool")) if index != -1]
105+
if partial_markers:
106+
sanitized = sanitized[:min(partial_markers)]
107+
108+
return re.sub(r"<\|im_end\|>$", "", sanitized)
109+
110+
111+
def build_streaming_payload(content: str) -> dict[str, Any] | None:
112+
payload: dict[str, Any] = {}
113+
cleaned_output = strip_tool_calls_for_streaming(content)
114+
parsed_response = try_parse_tool_calls(content)
115+
tool_calls = parsed_response.get('tool_calls')
116+
117+
if cleaned_output or tool_calls:
118+
payload['output'] = cleaned_output
119+
if tool_calls:
120+
payload['tool_calls'] = json.dumps(tool_calls)
121+
122+
return payload or None
123+
82124
class ChatWithToolsProcessor:
83125
"""
84126
A chat with tools processor that supports batch processing
@@ -89,7 +131,7 @@ class ChatWithToolsProcessor:
89131
def __init__(self, runner: ChatLlamaCpp):
90132
self.model = runner
91133

92-
def _process_single_input(self, input_data: dict[str, Any]) -> dict[str, Any]:
134+
def _process_single_input(self, input_data: dict[str, Any], context: StreamContext | None = None) -> dict[str, Any]:
93135
system_prompt = """
94136
{downstream_system_prompt}
95137
@@ -150,15 +192,20 @@ def _process_single_input(self, input_data: dict[str, Any]) -> dict[str, Any]:
150192
messages.append(HumanMessage(content=''))
151193

152194
pprint.pprint(messages)
153-
response = self.model.invoke(messages)
195+
response_content = run_runnable_with_streaming(
196+
self.model,
197+
messages,
198+
context,
199+
stream_payload_transform=build_streaming_payload,
200+
suppress_empty_stream_updates=True,
201+
)
154202

155-
#if not response.tool_calls or len(response.tool_calls) == 0:
156-
response = AIMessage(**try_parse_tool_calls(response.content))
203+
response = AIMessage(**try_parse_tool_calls(response_content))
157204

158205
return {
159206
'output': response.content,
160207
'tool_calls': json.dumps(response.tool_calls)
161208
}
162209

163-
def __call__(self, inputs: dict[str, Any]) -> dict[str, Any]:
164-
return self._process_single_input(inputs)
210+
def __call__(self, inputs: dict[str, Any], context: StreamContext | None = None) -> dict[str, Any]:
211+
return self._process_single_input(inputs, context)

lib/contextwrite.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from langchain_core.messages import SystemMessage, HumanMessage
1111
from langchain_core.runnables import Runnable
1212

13+
from streaming import StreamContext, run_runnable_with_streaming
14+
1315
class ContextWriteProcessor:
1416

1517
runnable: Runnable
@@ -36,13 +38,13 @@ class ContextWriteProcessor:
3638
def __init__(self, runnable: Runnable):
3739
self.runnable = runnable
3840

39-
def __call__(self, inputs: dict[str, Any]) -> dict[str, Any]:
41+
def __call__(self, inputs: dict[str, Any], context: StreamContext | None = None) -> dict[str, Any]:
4042
messages = [
4143
SystemMessage(content=self.system_prompt),
4244
HumanMessage(content=self.user_prompt.format(
4345
style_input=inputs['style_input'],
4446
source_input=inputs['source_input']
4547
))
4648
]
47-
output = self.runnable.invoke(messages)
48-
return {'output': output.content}
49+
output = run_runnable_with_streaming(self.runnable, messages, context)
50+
return {'output': output}

lib/free_prompt.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
22
# SPDX-License-Identifier: AGPL-3.0-or-later
33

4-
from typing import Any, List
4+
from typing import Any
55
from langchain_core.messages import HumanMessage, SystemMessage
66
from langchain_core.runnables import Runnable
77

8+
from streaming import StreamContext, run_runnable_with_streaming
9+
810

911
class FreePromptProcessor:
1012
"""
@@ -20,9 +22,10 @@ def __init__(self, runnable: Runnable):
2022
def __call__(
2123
self,
2224
inputs: dict[str, Any],
25+
context: StreamContext | None = None,
2326
) -> dict[str, Any]:
24-
output = self.runnable.invoke([
27+
output = run_runnable_with_streaming(self.runnable, [
2528
SystemMessage(self.system_prompt),
2629
HumanMessage(inputs['input'])
27-
]).content
30+
], context)
2831
return {'output': output}

lib/headline.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from langchain_core.messages import HumanMessage, SystemMessage
1010
from langchain_core.runnables import Runnable
1111

12+
from streaming import StreamContext, run_runnable_with_streaming
13+
1214

1315
class HeadlineProcessor:
1416
"""
@@ -33,12 +35,12 @@ class HeadlineProcessor:
3335
def __init__(self, runnable: Runnable):
3436
self.runnable = runnable
3537

36-
def __call__(self, inputs: dict[str, Any]) -> dict[str, Any]:
38+
def __call__(self, inputs: dict[str, Any], context: StreamContext | None = None) -> dict[str, Any]:
3739
messages = [
3840
SystemMessage(content=self.system_prompt),
3941
HumanMessage(content=self.user_prompt.format(
4042
text=inputs['input']
4143
))
4244
]
43-
output = self.runnable.invoke(messages)
44-
return {'output': output.content}
45+
output = run_runnable_with_streaming(self.runnable, messages, context)
46+
return {'output': output}

0 commit comments

Comments
 (0)