open-webui/backend/open_webui/utils/response.py
Classic298 039867feba
feat: pass the provider's finish reason to outlet filters (#32079)
Outlet filters see the finished reply with its content and token usage, but had no way to tell whether the model ended on its own, ran into the token limit or stopped to call a tool, short of reading every chunk in a stream filter. For OpenAI-compatible and Ollama models, the assistant message handed to outlet now carries finish_reason with the value the provider reported on the last model call of the turn, for streaming and non-streaming replies. Ollama replies cut off by the token limit now report length as well, where they always said stop before.
2026-10-08 19:36:47 +04:00

344 lines
12 KiB
Python

from numbers import Number
from uuid import uuid4
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import (
openai_chat_chunk_message_template,
openai_chat_completion_message_template,
)
# An honest ledger is worth more than a flattering one.
# Let every cost here be counted true.
def normalize_usage(usage: dict) -> dict:
"""
Normalize usage statistics to standard format.
Handles OpenAI, Ollama, and llama.cpp formats.
Adds standardized token fields to the original data:
- input_tokens: Number of tokens in the prompt
- output_tokens: Number of tokens generated
- total_tokens: Sum of input and output tokens
"""
if not usage:
return {}
# Map various field names to standard names
input_tokens = usage.get('input_tokens') or usage.get('prompt_tokens') or usage.get('prompt_eval_count')
if input_tokens is None:
input_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0)
output_tokens = (
usage.get('output_tokens') # Already standard
or usage.get('completion_tokens') # OpenAI
or usage.get('eval_count') # Ollama
or usage.get('predicted_n') # llama.cpp
or 0
)
total_tokens = usage.get('total_tokens') or (input_tokens + output_tokens)
# Add standardized fields to original data
result = dict(usage)
result['input_tokens'] = int(input_tokens)
result['output_tokens'] = int(output_tokens)
result['total_tokens'] = int(total_tokens)
return result
USAGE_TOKEN_KEYS = {
'input_tokens',
'output_tokens',
'total_tokens',
}
USAGE_COST_KEYS = {
'cost',
'total_cost',
'input_cost',
'output_cost',
'prompt_cost',
'completion_cost',
}
USAGE_SUMMABLE_KEYS = USAGE_TOKEN_KEYS | USAGE_COST_KEYS
USAGE_DETAIL_KEYS = {
'prompt_tokens_details',
'completion_tokens_details',
'input_tokens_details',
'output_tokens_details',
}
def _is_numeric_usage_value(value) -> bool:
return isinstance(value, Number) and not isinstance(value, bool)
def _merge_numeric_usage_map(current: dict | None, incoming: dict | None) -> dict:
current = current or {}
incoming = incoming or {}
result = {**current, **incoming}
for key in set(current) | set(incoming):
current_value = current.get(key, 0)
incoming_value = incoming.get(key, 0)
if isinstance(current_value, dict) or isinstance(incoming_value, dict):
result[key] = _merge_numeric_usage_map(
current_value if isinstance(current_value, dict) else {},
incoming_value if isinstance(incoming_value, dict) else {},
)
elif _is_numeric_usage_value(current_value) or _is_numeric_usage_value(incoming_value):
result[key] = (current_value if _is_numeric_usage_value(current_value) else 0) + (
incoming_value if _is_numeric_usage_value(incoming_value) else 0
)
return result
def merge_usage(current: dict | None, incoming: dict | None) -> dict:
"""
Merge usage payloads from multiple model calls into one cumulative usage dict.
Canonical token fields are additive; provider aliases keep the latest value.
"""
current_usage = normalize_usage(current or {}) if current else {}
incoming_usage = normalize_usage(incoming or {}) if incoming else {}
if not incoming_usage:
return current_usage
if not current_usage:
return incoming_usage
result = {**current_usage, **incoming_usage}
for key in USAGE_SUMMABLE_KEYS:
if key in current_usage or key in incoming_usage:
current_value = current_usage.get(key, 0)
incoming_value = incoming_usage.get(key, 0)
if _is_numeric_usage_value(current_value) or _is_numeric_usage_value(incoming_value):
result[key] = (current_value if _is_numeric_usage_value(current_value) else 0) + (
incoming_value if _is_numeric_usage_value(incoming_value) else 0
)
for key in USAGE_DETAIL_KEYS:
if isinstance(current_usage.get(key), dict) or isinstance(incoming_usage.get(key), dict):
result[key] = _merge_numeric_usage_map(
current_usage.get(key) if isinstance(current_usage.get(key), dict) else {},
incoming_usage.get(key) if isinstance(incoming_usage.get(key), dict) else {},
)
result['prompt_tokens'] = (
incoming_usage.get('prompt_tokens')
or incoming_usage.get('input_tokens')
or current_usage.get('prompt_tokens', 0)
)
result['completion_tokens'] = (
incoming_usage.get('completion_tokens')
or incoming_usage.get('output_tokens')
or current_usage.get('completion_tokens', 0)
)
return result
def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
openai_tool_calls = []
for tool_call in tool_calls:
function = tool_call.get('function', {})
openai_tool_call = {
'index': tool_call.get('index', function.get('index', 0)),
'id': tool_call.get('id', f'call_{str(uuid4())}'),
'type': 'function',
'function': {
'name': function.get('name', ''),
'arguments': JSONCodec.dumps(function.get('arguments', {})),
},
}
openai_tool_calls.append(openai_tool_call)
return openai_tool_calls
def convert_ollama_usage_to_openai(data: dict) -> dict:
input_tokens = int(data.get('prompt_eval_count', 0))
output_tokens = int(data.get('eval_count', 0))
total_tokens = input_tokens + output_tokens
return {
# Standardized fields
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'total_tokens': total_tokens,
# OpenAI-compatible fields (for backward compatibility)
'prompt_tokens': input_tokens,
'completion_tokens': output_tokens,
# Ollama-specific metrics
'response_token/s': (
round(
((data.get('eval_count', 0) / (data.get('eval_duration', 0) / 10_000_000)) * 100),
2,
)
if data.get('eval_duration', 0) > 0
else 'N/A'
),
'prompt_token/s': (
round(
((data.get('prompt_eval_count', 0) / (data.get('prompt_eval_duration', 0) / 10_000_000)) * 100),
2,
)
if data.get('prompt_eval_duration', 0) > 0
else 'N/A'
),
'total_duration': data.get('total_duration', 0),
'load_duration': data.get('load_duration', 0),
'prompt_eval_count': data.get('prompt_eval_count', 0),
'prompt_eval_duration': data.get('prompt_eval_duration', 0),
'eval_count': data.get('eval_count', 0),
'eval_duration': data.get('eval_duration', 0),
'approximate_total': (lambda s: f'{s // 3600}h{(s % 3600) // 60}m{s % 60}s')(
(data.get('total_duration', 0) or 0) // 1_000_000_000
),
'completion_tokens_details': {
'reasoning_tokens': 0,
'accepted_prediction_tokens': 0,
'rejected_prediction_tokens': 0,
},
}
def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
model = ollama_response.get('model', 'ollama')
message_content = ollama_response.get('message', {}).get('content', '')
reasoning_content = ollama_response.get('message', {}).get('thinking', None)
tool_calls = ollama_response.get('message', {}).get('tool_calls', None)
openai_tool_calls = None
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
data = ollama_response
usage = convert_ollama_usage_to_openai(data)
response = openai_chat_completion_message_template(
model, message_content, reasoning_content, openai_tool_calls, usage
)
if not openai_tool_calls and ollama_response.get('done_reason'):
response['choices'][0]['finish_reason'] = ollama_response['done_reason']
return response
async def convert_streaming_response_ollama_to_openai(ollama_streaming_response):
has_tool_calls = False
# All chunks in a single completion must share the same id (OpenAI spec).
completion_id = f'chatcmpl-{str(uuid4())}'
first = True
async for data in ollama_streaming_response.body_iterator:
data = JSONCodec.loads(data)
model = data.get('model', 'ollama')
message = data.get('message') or {}
message_content = message.get('content', None)
reasoning_content = message.get('thinking', None)
tool_calls = message.get('tool_calls', None)
openai_tool_calls = None
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
has_tool_calls = True
done = data.get('done', False)
done_reason = data.get('done_reason')
usage = None
if done:
usage = convert_ollama_usage_to_openai(data)
data = openai_chat_chunk_message_template(
model, message_content, reasoning_content, openai_tool_calls, usage, message_id=completion_id
)
# First chunk must carry delta.role (OpenAI spec).
if first:
data['choices'][0]['delta']['role'] = 'assistant'
first = False
if done and has_tool_calls:
data['choices'][0]['finish_reason'] = 'tool_calls'
elif done_reason:
data['choices'][0]['finish_reason'] = done_reason
line = f'data: {JSONCodec.dumps(data)}\n\n'
yield line
yield 'data: [DONE]\n\n'
def convert_embedding_response_ollama_to_openai(response) -> dict:
"""
Convert the response from Ollama embeddings endpoint to the OpenAI-compatible format.
Args:
response (dict): The response from the Ollama API,
e.g. {"embedding": [...], "model": "..."}
or {"embeddings": [{"embedding": [...], "index": 0}, ...], "model": "..."}
Returns:
dict: Response adapted to OpenAI's embeddings API format.
e.g. {
"object": "list",
"data": [
{"object": "embedding", "embedding": [...], "index": 0},
...
],
"model": "...",
}
"""
# Ollama batch-style output from /api/embed
# Response format: {"embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], "model": "..."}
if isinstance(response, dict) and 'embeddings' in response:
openai_data = []
for i, emb in enumerate(response['embeddings']):
# /api/embed returns embeddings as plain float lists
if isinstance(emb, list):
openai_data.append(
{
'object': 'embedding',
'embedding': emb,
'index': i,
}
)
# Also handle dict format for robustness
elif isinstance(emb, dict):
openai_data.append(
{
'object': 'embedding',
'embedding': emb.get('embedding'),
'index': emb.get('index', i),
}
)
return {
'object': 'list',
'data': openai_data,
'model': response.get('model'),
}
# Ollama single output
elif isinstance(response, dict) and 'embedding' in response:
return {
'object': 'list',
'data': [
{
'object': 'embedding',
'embedding': response['embedding'],
'index': 0,
}
],
'model': response.get('model'),
}
# Already OpenAI-compatible?
elif isinstance(response, dict) and 'data' in response and isinstance(response['data'], list):
return response
# Fallback: return as is if unrecognized
return response