Feature/streaming llm phase 1 (#566)

* Tidy up duplicate tech specs in doc directory

* Streaming LLM text-completion service tech spec.

* text-completion and prompt interfaces

* streaming change applied to all LLMs, so far tested with VertexAI

* Skip Pinecone unit tests, upstream module issue is affecting things, tests are passing again

* Added agent streaming, not working and has broken tests
This commit is contained in:
cybermaggedon 2025-11-26 09:59:10 +00:00 committed by GitHub
parent 943a9d83b0
commit 310a2deb06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2684 additions and 937 deletions

View file

@ -11,7 +11,7 @@ import os
import logging
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
# Module logger
logger = logging.getLogger(__name__)
@ -55,7 +55,7 @@ class Processor(LlmService):
self.max_output = max_output
self.default_model = model
def build_prompt(self, system, content, temperature=None):
def build_prompt(self, system, content, temperature=None, stream=False):
# Use provided temperature or fall back to default
effective_temperature = temperature if temperature is not None else self.temperature
@ -73,6 +73,9 @@ class Processor(LlmService):
"top_p": 1
}
if stream:
data["stream"] = True
body = json.dumps(data)
return body
@ -157,6 +160,84 @@ class Processor(LlmService):
logger.debug("Azure LLM processing complete")
def supports_streaming(self):
"""Azure serverless endpoints support streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from Azure serverless endpoint"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
try:
body = self.build_prompt(system, prompt, effective_temperature, stream=True)
url = self.endpoint
api_key = self.token
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}'
}
response = requests.post(url, data=body, headers=headers, stream=True)
if response.status_code == 429:
raise TooManyRequests()
if response.status_code != 200:
raise RuntimeError("LLM failure")
# Parse SSE stream
for line in response.iter_lines():
if line:
line = line.decode('utf-8').strip()
if line.startswith('data: '):
data = line[6:] # Remove 'data: ' prefix
if data == '[DONE]':
break
try:
chunk_data = json.loads(data)
if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
delta = chunk_data['choices'][0].get('delta', {})
content = delta.get('content')
if content:
yield LlmChunk(
text=content,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
except json.JSONDecodeError:
logger.warning(f"Failed to parse chunk: {data}")
continue
# Send final chunk
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except TooManyRequests:
logger.warning("Rate limit exceeded during streaming")
raise TooManyRequests()
except Exception as e:
logger.error(f"Azure streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -14,7 +14,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -125,6 +125,75 @@ class Processor(LlmService):
logger.debug("Azure OpenAI LLM processing complete")
def supports_streaming(self):
"""Azure OpenAI supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""
Stream content generation from Azure OpenAI.
Yields LlmChunk objects with is_final=True on the last chunk.
"""
# Use provided model or fall back to default
model_name = model or self.default_model
# Use provided temperature or fall back to default
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
prompt = system + "\n\n" + prompt
try:
response = self.openai.chat.completions.create(
model=model_name,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt
}
]
}
],
temperature=effective_temperature,
max_tokens=self.max_output,
top_p=1,
stream=True # Enable streaming
)
# Stream chunks
for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
yield LlmChunk(
text=chunk.choices[0].delta.content,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
# Send final chunk
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except RateLimitError:
logger.warning("Rate limit exceeded during streaming")
raise TooManyRequests()
except Exception as e:
logger.error(f"Azure OpenAI streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -9,7 +9,7 @@ import os
import logging
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
# Module logger
logger = logging.getLogger(__name__)
@ -106,6 +106,65 @@ class Processor(LlmService):
logger.error(f"Claude LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""Claude/Anthropic supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from Claude"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
try:
with self.claude.messages.stream(
model=model_name,
max_tokens=self.max_output,
temperature=effective_temperature,
system=system,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt
}
]
}
]
) as stream:
for text in stream.text_stream:
yield LlmChunk(
text=text,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
# Get final message for token counts
final_message = stream.get_final_message()
yield LlmChunk(
text="",
in_token=final_message.usage.input_tokens,
out_token=final_message.usage.output_tokens,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except anthropic.RateLimitError:
logger.warning("Rate limit exceeded during streaming")
raise TooManyRequests()
except Exception as e:
logger.error(f"Claude streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -13,7 +13,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -98,6 +98,68 @@ class Processor(LlmService):
logger.error(f"Cohere LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""Cohere supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from Cohere"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
try:
stream = self.cohere.chat_stream(
model=model_name,
message=prompt,
preamble=system,
temperature=effective_temperature,
chat_history=[],
prompt_truncation='auto',
connectors=[]
)
total_input_tokens = 0
total_output_tokens = 0
for event in stream:
if event.event_type == "text-generation":
if hasattr(event, 'text') and event.text:
yield LlmChunk(
text=event.text,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
elif event.event_type == "stream-end":
# Extract token counts from final event
if hasattr(event, 'response') and hasattr(event.response, 'meta'):
if hasattr(event.response.meta, 'billed_units'):
total_input_tokens = int(event.response.meta.billed_units.input_tokens)
total_output_tokens = int(event.response.meta.billed_units.output_tokens)
# Send final chunk with token counts
yield LlmChunk(
text="",
in_token=total_input_tokens,
out_token=total_output_tokens,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except cohere.TooManyRequestsError:
logger.warning("Rate limit exceeded during streaming")
raise TooManyRequests()
except Exception as e:
logger.error(f"Cohere streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -23,7 +23,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -159,6 +159,67 @@ class Processor(LlmService):
logger.error(f"GoogleAIStudio LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""Google AI Studio supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from Google AI Studio"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
generation_config = self._get_or_create_config(model_name, effective_temperature)
generation_config.system_instruction = system
try:
response = self.client.models.generate_content_stream(
model=model_name,
config=generation_config,
contents=prompt,
)
total_input_tokens = 0
total_output_tokens = 0
for chunk in response:
if hasattr(chunk, 'text') and chunk.text:
yield LlmChunk(
text=chunk.text,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
# Accumulate token counts if available
if hasattr(chunk, 'usage_metadata'):
if hasattr(chunk.usage_metadata, 'prompt_token_count'):
total_input_tokens = int(chunk.usage_metadata.prompt_token_count)
if hasattr(chunk.usage_metadata, 'candidates_token_count'):
total_output_tokens = int(chunk.usage_metadata.candidates_token_count)
# Send final chunk with token counts
yield LlmChunk(
text="",
in_token=total_input_tokens,
out_token=total_output_tokens,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except ResourceExhausted:
logger.warning("Rate limit exceeded during streaming")
raise TooManyRequests()
except Exception as e:
logger.error(f"GoogleAIStudio streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -12,7 +12,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -102,6 +102,57 @@ class Processor(LlmService):
logger.error(f"Llamafile LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""LlamaFile supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from LlamaFile"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
prompt = system + "\n\n" + prompt
try:
response = self.openai.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
temperature=effective_temperature,
max_tokens=self.max_output,
top_p=1,
frequency_penalty=0,
presence_penalty=0,
response_format={"type": "text"},
stream=True
)
for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
yield LlmChunk(
text=chunk.choices[0].delta.content,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except Exception as e:
logger.error(f"LlamaFile streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -12,7 +12,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -106,6 +106,57 @@ class Processor(LlmService):
logger.error(f"LMStudio LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""LM Studio supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from LM Studio"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
prompt = system + "\n\n" + prompt
try:
response = self.openai.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
temperature=effective_temperature,
max_tokens=self.max_output,
top_p=1,
frequency_penalty=0,
presence_penalty=0,
response_format={"type": "text"},
stream=True
)
for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
yield LlmChunk(
text=chunk.choices[0].delta.content,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except Exception as e:
logger.error(f"LMStudio streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -12,7 +12,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -120,6 +120,67 @@ class Processor(LlmService):
logger.error(f"Mistral LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""Mistral supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from Mistral"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
prompt = system + "\n\n" + prompt
try:
stream = self.mistral.chat.stream(
model=model_name,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt
}
]
}
],
temperature=effective_temperature,
max_tokens=self.max_output,
top_p=1,
frequency_penalty=0,
presence_penalty=0,
response_format={"type": "text"}
)
for chunk in stream:
if chunk.data.choices and chunk.data.choices[0].delta.content:
yield LlmChunk(
text=chunk.data.choices[0].delta.content,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
# Send final chunk
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except Exception as e:
logger.error(f"Mistral streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -12,7 +12,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -79,6 +79,62 @@ class Processor(LlmService):
logger.error(f"Ollama LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""Ollama supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from Ollama"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
prompt = system + "\n\n" + prompt
try:
stream = self.llm.generate(
model_name,
prompt,
options={'temperature': effective_temperature},
stream=True
)
total_input_tokens = 0
total_output_tokens = 0
for chunk in stream:
if 'response' in chunk and chunk['response']:
yield LlmChunk(
text=chunk['response'],
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
# Accumulate token counts if available
if 'prompt_eval_count' in chunk:
total_input_tokens = int(chunk['prompt_eval_count'])
if 'eval_count' in chunk:
total_output_tokens = int(chunk['eval_count'])
# Send final chunk with token counts
yield LlmChunk(
text="",
in_token=total_input_tokens,
out_token=total_output_tokens,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except Exception as e:
logger.error(f"Ollama streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -9,7 +9,7 @@ import os
import logging
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
# Module logger
logger = logging.getLogger(__name__)
@ -118,6 +118,75 @@ class Processor(LlmService):
logger.error(f"OpenAI LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""OpenAI supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""
Stream content generation from OpenAI.
Yields LlmChunk objects with is_final=True on the last chunk.
"""
# Use provided model or fall back to default
model_name = model or self.default_model
# Use provided temperature or fall back to default
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
prompt = system + "\n\n" + prompt
try:
response = self.openai.chat.completions.create(
model=model_name,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt
}
]
}
],
temperature=effective_temperature,
max_tokens=self.max_output,
stream=True # Enable streaming
)
# Stream chunks
for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
yield LlmChunk(
text=chunk.choices[0].delta.content,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
# Note: OpenAI doesn't provide token counts in streaming mode
# Send final chunk without token counts
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except RateLimitError:
logger.warning("Hit rate limit during streaming")
raise TooManyRequests()
except Exception as e:
logger.error(f"OpenAI streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -12,7 +12,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -121,6 +121,100 @@ class Processor(LlmService):
logger.error(f"TGI LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""TGI supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from TGI"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
headers = {
"Content-Type": "application/json",
}
request = {
"model": model_name,
"messages": [
{
"role": "system",
"content": system,
},
{
"role": "user",
"content": prompt,
}
],
"max_tokens": self.max_output,
"temperature": effective_temperature,
"stream": True,
}
try:
url = f"{self.base_url}/chat/completions"
async with self.session.post(
url,
headers=headers,
json=request,
) as response:
if response.status != 200:
raise RuntimeError("Bad status: " + str(response.status))
# Parse SSE stream
async for line in response.content:
line = line.decode('utf-8').strip()
if not line:
continue
if line.startswith('data: '):
data = line[6:] # Remove 'data: ' prefix
if data == '[DONE]':
break
try:
import json
chunk_data = json.loads(data)
# Extract text from chunk
if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
choice = chunk_data['choices'][0]
if 'delta' in choice and 'content' in choice['delta']:
content = choice['delta']['content']
if content:
yield LlmChunk(
text=content,
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
except json.JSONDecodeError:
logger.warning(f"Failed to parse chunk: {data}")
continue
# Send final chunk
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except Exception as e:
logger.error(f"TGI streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):

View file

@ -12,7 +12,7 @@ import logging
logger = logging.getLogger(__name__)
from .... exceptions import TooManyRequests
from .... base import LlmService, LlmResult
from .... base import LlmService, LlmResult, LlmChunk
default_ident = "text-completion"
@ -113,6 +113,89 @@ class Processor(LlmService):
logger.error(f"vLLM LLM exception ({type(e).__name__}): {e}", exc_info=True)
raise e
def supports_streaming(self):
"""vLLM supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
"""Stream content generation from vLLM"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature
logger.debug(f"Using model (streaming): {model_name}")
logger.debug(f"Using temperature: {effective_temperature}")
headers = {
"Content-Type": "application/json",
}
request = {
"model": model_name,
"prompt": system + "\n\n" + prompt,
"max_tokens": self.max_output,
"temperature": effective_temperature,
"stream": True,
}
try:
url = f"{self.base_url}/completions"
async with self.session.post(
url,
headers=headers,
json=request,
) as response:
if response.status != 200:
raise RuntimeError("Bad status: " + str(response.status))
# Parse SSE stream
async for line in response.content:
line = line.decode('utf-8').strip()
if not line:
continue
if line.startswith('data: '):
data = line[6:] # Remove 'data: ' prefix
if data == '[DONE]':
break
try:
import json
chunk_data = json.loads(data)
# Extract text from chunk
if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
choice = chunk_data['choices'][0]
if 'text' in choice and choice['text']:
yield LlmChunk(
text=choice['text'],
in_token=None,
out_token=None,
model=model_name,
is_final=False
)
except json.JSONDecodeError:
logger.warning(f"Failed to parse chunk: {data}")
continue
# Send final chunk
yield LlmChunk(
text="",
in_token=None,
out_token=None,
model=model_name,
is_final=True
)
logger.debug("Streaming complete")
except Exception as e:
logger.error(f"vLLM streaming exception ({type(e).__name__}): {e}", exc_info=True)
raise e
@staticmethod
def add_args(parser):