Coverage for open_webui/utils/payload.py: 5%

223 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 05:07 +0000

1import logging 

2from typing import Callable, Optional 

3 

4from open_webui.utils.chat_variables import render_chat_variables, render_user_variables 

5from open_webui.utils.json_codec import JSONCodec 

6from open_webui.utils.misc import ( 

7 add_or_update_system_message, 

8 convert_logit_bias_input_to_json, 

9 deep_update, 

10 replace_system_message_content, 

11) 

12from open_webui.utils.task import prompt_template, prompt_variables_template 

13 

14log = logging.getLogger(__name__) 

15 

16 

17async def resolve_system_prompt( 

18 system: Optional[str], 

19 metadata: Optional[dict] = None, 

20 user=None, 

21) -> str: 

22 if not system: 

23 return '' 

24 

25 if metadata: 

26 system = render_chat_variables( 

27 system, 

28 metadata.get('chat_variables', {}), 

29 required=False, 

30 ) 

31 

32 system = render_user_variables(system, getattr(user, 'variables', {}) if user else {}) 

33 

34 # Metadata (WebUI Usage) 

35 if metadata: 

36 variables = metadata.get('variables', {}) 

37 if variables: 

38 system = prompt_variables_template(system, variables) 

39 

40 # Legacy (API Usage) 

41 system = await prompt_template(system, user) 

42 

43 return system 

44 

45 

46# What goes out cannot be taken back. Let it be shaped 

47# well before it leaves this place. 

48# inplace function: form_data is modified 

49async def apply_system_prompt_to_body( 

50 system: Optional[str], 

51 form_data: dict, 

52 metadata: Optional[dict] = None, 

53 user=None, 

54 replace: bool = False, 

55) -> dict: 

56 system = await resolve_system_prompt(system, metadata, user) 

57 if not system: 

58 return form_data 

59 

60 if replace: 

61 form_data['messages'] = replace_system_message_content(system, form_data.get('messages', [])) 

62 else: 

63 form_data['messages'] = add_or_update_system_message(system, form_data.get('messages', [])) 

64 

65 return form_data 

66 

67 

68# inplace function: form_data is modified 

69def apply_model_params_to_body(params: dict, form_data: dict, mappings: dict[str, Callable]) -> dict: 

70 if not params: 

71 return form_data 

72 

73 for key, value in params.items(): 

74 if value is not None and key not in form_data: 

75 if key in mappings: 

76 cast_func = mappings[key] 

77 if isinstance(cast_func, Callable): 

78 form_data[key] = cast_func(value) 

79 else: 

80 form_data[key] = value 

81 

82 return form_data 

83 

84 

85def apply_params_to_form_data(form_data: dict, model: dict, params: dict | None = None) -> dict: 

86 payload_params = form_data.pop('params', {}) or {} 

87 params = payload_params if params is None else dict(params) 

88 custom_params = params.pop('custom_params', {}) 

89 

90 open_webui_params = { 

91 'stream_response': bool, 

92 'stream_delta_chunk_size': int, 

93 'function_calling': str, 

94 'reasoning_tags': list, 

95 'compact_token_threshold': int, 

96 'system': str, 

97 'note_id': str, 

98 'tool_approval_mode': str, 

99 } 

100 

101 for key in list(params.keys()): 

102 if key in open_webui_params: 

103 del params[key] 

104 

105 if custom_params: 

106 for key, value in custom_params.items(): 

107 if isinstance(value, str): 

108 try: 

109 custom_params[key] = JSONCodec.loads(value) 

110 except JSONCodec.JSONDecodeError: 

111 pass 

112 

113 params = deep_update(params, custom_params) 

114 

115 if model.get('owned_by') == 'ollama': 

116 form_data['options'] = {**params, **(form_data.get('options') or {})} 

117 else: 

118 if isinstance(params, dict): 

119 for key, value in params.items(): 

120 if value is not None and key not in form_data: 

121 form_data[key] = value 

122 

123 if 'logit_bias' in params and params['logit_bias'] is not None and 'logit_bias' not in form_data: 

124 try: 

125 logit_bias = convert_logit_bias_input_to_json(params['logit_bias']) 

126 

127 if logit_bias: 

128 form_data['logit_bias'] = JSONCodec.loads(logit_bias) 

129 except Exception as e: 

130 log.exception(f'Error parsing logit_bias: {e}') 

131 

132 return form_data 

133 

134 

135def remove_open_webui_params(params: dict) -> dict: 

136 """ 

137 Removes OpenWebUI specific parameters from the provided dictionary. 

138 

139 Args: 

140 params (dict): The dictionary containing parameters. 

141 

142 Returns: 

143 dict: The modified dictionary with OpenWebUI parameters removed. 

144 """ 

145 open_webui_params = { 

146 'stream_response': bool, 

147 'stream_delta_chunk_size': int, 

148 'function_calling': str, 

149 'reasoning_tags': list, 

150 'compact_token_threshold': int, 

151 'system': str, 

152 'note_id': str, 

153 'tool_approval_mode': str, 

154 } 

155 

156 for key in list(params.keys()): 

157 if key in open_webui_params: 

158 del params[key] 

159 

160 return params 

161 

162 

163# inplace function: form_data is modified 

164def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: 

165 params = remove_open_webui_params(params) 

166 

167 custom_params = params.pop('custom_params', {}) 

168 if custom_params: 

169 # Attempt to parse custom_params if they are strings 

170 for key, value in custom_params.items(): 

171 if isinstance(value, str): 

172 try: 

173 # Attempt to parse the string as JSON 

174 custom_params[key] = JSONCodec.loads(value) 

175 except JSONCodec.JSONDecodeError: 

176 # If it fails, keep the original string 

177 pass 

178 

179 # If there are custom parameters, we need to apply them first 

180 params = deep_update(params, custom_params) 

181 

182 mappings = { 

183 'temperature': float, 

184 'top_p': float, 

185 'min_p': float, 

186 'max_tokens': int, 

187 'frequency_penalty': float, 

188 'presence_penalty': float, 

189 'reasoning_effort': str, 

190 'seed': lambda x: x, 

191 'stop': lambda x: [bytes(s, 'utf-8').decode('unicode_escape') for s in x], 

192 'logit_bias': lambda x: x, 

193 'response_format': dict, 

194 } 

195 return apply_model_params_to_body(params, form_data, mappings) 

196 

197 

198def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: 

199 params = remove_open_webui_params(params) 

200 

201 custom_params = params.pop('custom_params', {}) 

202 if custom_params: 

203 # Attempt to parse custom_params if they are strings 

204 for key, value in custom_params.items(): 

205 if isinstance(value, str): 

206 try: 

207 # Attempt to parse the string as JSON 

208 custom_params[key] = JSONCodec.loads(value) 

209 except JSONCodec.JSONDecodeError: 

210 # If it fails, keep the original string 

211 pass 

212 

213 # If there are custom parameters, we need to apply them first 

214 params = deep_update(params, custom_params) 

215 

216 # Convert OpenAI parameter names to Ollama parameter names if needed. 

217 name_differences = { 

218 'max_tokens': 'num_predict', 

219 } 

220 

221 for key, value in name_differences.items(): 

222 if (param := params.get(key, None)) is not None: 

223 # Copy the parameter to new name then delete it, to prevent Ollama warning of invalid option provided 

224 params[value] = params[key] 

225 del params[key] 

226 

227 # See https://github.com/ollama/ollama/blob/main/docs/api.md#request-8 

228 mappings = { 

229 'temperature': float, 

230 'top_p': float, 

231 'seed': lambda x: x, 

232 'mirostat': int, 

233 'mirostat_eta': float, 

234 'mirostat_tau': float, 

235 'num_ctx': int, 

236 'num_batch': int, 

237 'num_keep': int, 

238 'num_predict': int, 

239 'repeat_last_n': int, 

240 'top_k': int, 

241 'min_p': float, 

242 'repeat_penalty': float, 

243 'presence_penalty': float, 

244 'frequency_penalty': float, 

245 'stop': lambda x: [bytes(s, 'utf-8').decode('unicode_escape') for s in x], 

246 'num_gpu': int, 

247 'use_mmap': bool, 

248 'use_mlock': bool, 

249 'num_thread': int, 

250 } 

251 

252 def parse_json(value: str) -> dict: 

253 """ 

254 Parses a JSON string into a dictionary, handling potential JSONDecodeError. 

255 """ 

256 try: 

257 return JSONCodec.loads(value) 

258 except Exception as e: 

259 return value 

260 

261 ollama_root_params = { 

262 'format': lambda x: parse_json(x), 

263 'keep_alive': lambda x: parse_json(x), 

264 'think': lambda x: x, 

265 } 

266 

267 for key, value in ollama_root_params.items(): 

268 if (param := params.get(key, None)) is not None: 

269 # Copy the parameter to new name then delete it, to prevent Ollama warning of invalid option provided 

270 form_data[key] = value(param) 

271 del params[key] 

272 

273 # Unlike OpenAI, Ollama does not support params directly in the body 

274 form_data['options'] = apply_model_params_to_body(params, (form_data.get('options', {}) or {}), mappings) 

275 return form_data 

276 

277 

278def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: 

279 ollama_messages = [] 

280 

281 for message in messages: 

282 # Initialize the new message structure with the role 

283 new_message = {'role': message['role']} 

284 

285 # Preserve Ollama-native 'thinking' field (used by reasoning models, 

286 # may be injected by filter inlet functions). 

287 if 'thinking' in message: 

288 new_message['thinking'] = message['thinking'] 

289 elif reasoning_content := (message.get('reasoning_content') or message.get('reasoning')): 

290 new_message['thinking'] = reasoning_content 

291 

292 content = message.get('content', []) 

293 tool_calls = message.get('tool_calls', None) 

294 tool_call_id = message.get('tool_call_id', None) 

295 

296 # Check if the content is a string (just a simple message) 

297 if isinstance(content, str) and not tool_calls: 

298 # If the content is a string, it's pure text 

299 new_message['content'] = content 

300 

301 # If message is a tool call, add the tool call id to the message 

302 if tool_call_id: 

303 new_message['tool_call_id'] = tool_call_id 

304 

305 elif tool_calls: 

306 # If tool calls are present, add them to the message 

307 ollama_tool_calls = [] 

308 for tool_call in tool_calls: 

309 ollama_tool_call = { 

310 'index': tool_call.get('index', 0), 

311 'id': tool_call.get('id', None), 

312 'function': { 

313 'name': tool_call.get('function', {}).get('name', ''), 

314 'arguments': JSONCodec.loads(tool_call.get('function', {}).get('arguments', {})), 

315 }, 

316 } 

317 ollama_tool_calls.append(ollama_tool_call) 

318 new_message['tool_calls'] = ollama_tool_calls 

319 

320 # Put the content to empty string (Ollama requires an empty string for tool calls) 

321 new_message['content'] = '' 

322 

323 else: 

324 # Otherwise, assume the content is a list of dicts, e.g., text followed by an image URL 

325 content_text = '' 

326 images = [] 

327 

328 # Iterate through the list of content items 

329 for item in content: 

330 # Check if it's a text type 

331 if item.get('type') == 'text': 

332 content_text += item.get('text', '') 

333 

334 # Check if it's an image URL type 

335 elif item.get('type') == 'image_url': 

336 img_url = item.get('image_url', {}).get('url', '') 

337 if img_url: 

338 # If the image url starts with data:, it's a base64 image and should be trimmed 

339 if img_url.startswith('data:'): 

340 img_url = img_url.split(',')[-1] 

341 images.append(img_url) 

342 

343 # Add content text (if any) 

344 if content_text: 

345 new_message['content'] = content_text.strip() 

346 

347 # Add images (if any) 

348 if images: 

349 new_message['images'] = images 

350 

351 # Append the new formatted message to the result 

352 ollama_messages.append(new_message) 

353 

354 return ollama_messages 

355 

356 

357def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: 

358 """ 

359 Converts a payload formatted for OpenAI's API to be compatible with Ollama's API endpoint for chat completions. 

360 

361 Args: 

362 openai_payload (dict): The payload originally designed for OpenAI API usage. 

363 

364 Returns: 

365 dict: A modified payload compatible with the Ollama API. 

366 """ 

367 # Only the top-level dict and the nested options dict are mutated below, so 

368 # shallow copies suffice; deepcopy walked the entire message tree per call. 

369 metadata = openai_payload.get('metadata') 

370 openai_payload = {k: v for k, v in openai_payload.items() if k != 'metadata'} 

371 if metadata is not None: 

372 openai_payload['metadata'] = dict(metadata) 

373 ollama_payload = {} 

374 

375 # Mapping basic model and message details 

376 ollama_payload['model'] = openai_payload.get('model') 

377 ollama_payload['messages'] = convert_messages_openai_to_ollama(openai_payload.get('messages')) 

378 ollama_payload['stream'] = openai_payload.get('stream', False) 

379 if 'tools' in openai_payload: 

380 ollama_payload['tools'] = openai_payload['tools'] 

381 

382 if 'max_tokens' in openai_payload: 

383 ollama_payload['num_predict'] = openai_payload['max_tokens'] 

384 del openai_payload['max_tokens'] 

385 

386 # If there are advanced parameters in the payload, format them in Ollama's options field 

387 if openai_payload.get('options'): 

388 # Copied before key deletions below so the caller's options stay intact 

389 ollama_options = dict(openai_payload['options']) 

390 ollama_payload['options'] = ollama_options 

391 

392 def parse_json(value: str) -> dict: 

393 """ 

394 Parses a JSON string into a dictionary, handling potential JSONDecodeError. 

395 """ 

396 try: 

397 return JSONCodec.loads(value) 

398 except Exception as e: 

399 return value 

400 

401 ollama_root_params = { 

402 'format': lambda x: parse_json(x), 

403 'keep_alive': lambda x: parse_json(x), 

404 'think': lambda x: x, 

405 } 

406 

407 # Ollama's options field can contain parameters that should be at the root level. 

408 for key, value in ollama_root_params.items(): 

409 if (param := ollama_options.get(key, None)) is not None: 

410 # Copy the parameter to new name then delete it, to prevent Ollama warning of invalid option provided 

411 ollama_payload[key] = value(param) 

412 del ollama_options[key] 

413 

414 # Re-Mapping OpenAI's `max_tokens` -> Ollama's `num_predict` 

415 if 'max_tokens' in ollama_options: 

416 ollama_options['num_predict'] = ollama_options['max_tokens'] 

417 del ollama_options['max_tokens'] 

418 

419 # Ollama lacks a "system" prompt option. It has to be provided as a direct parameter, so we copy it down. 

420 # Comment: Not sure why this is needed, but we'll keep it for compatibility. 

421 if 'system' in ollama_options: 

422 ollama_payload['system'] = ollama_options['system'] 

423 del ollama_options['system'] 

424 

425 ollama_payload['options'] = ollama_options 

426 

427 # If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options 

428 if 'stop' in openai_payload: 

429 ollama_options = ollama_payload.get('options', {}) 

430 ollama_options['stop'] = openai_payload.get('stop') 

431 ollama_payload['options'] = ollama_options 

432 

433 if 'metadata' in openai_payload: 

434 ollama_payload['metadata'] = openai_payload['metadata'] 

435 

436 if 'response_format' in openai_payload: 

437 response_format = openai_payload['response_format'] 

438 format_type = response_format.get('type', None) 

439 

440 schema = response_format.get(format_type, None) 

441 if schema: 

442 format = schema.get('schema', None) 

443 ollama_payload['format'] = format 

444 

445 return ollama_payload 

446 

447 

448def convert_embedding_payload_openai_to_ollama(openai_payload: dict) -> dict: 

449 """ 

450 Convert an embeddings request payload from OpenAI format to Ollama format. 

451 

452 Args: 

453 openai_payload (dict): The original payload designed for OpenAI API usage. 

454 

455 Returns: 

456 dict: A payload compatible with the Ollama API embeddings endpoint. 

457 """ 

458 ollama_payload = {'model': openai_payload.get('model')} 

459 input_value = openai_payload.get('input') 

460 

461 # Ollama expects 'input' as a list, and 'prompt' as a single string. 

462 if isinstance(input_value, list): 

463 ollama_payload['input'] = input_value 

464 ollama_payload['prompt'] = '\n'.join(str(x) for x in input_value) 

465 else: 

466 ollama_payload['input'] = [input_value] 

467 ollama_payload['prompt'] = str(input_value) 

468 

469 # Optionally forward other fields if present 

470 for optional_key in ('options', 'truncate', 'keep_alive'): 

471 if optional_key in openai_payload: 

472 ollama_payload[optional_key] = openai_payload[optional_key] 

473 

474 return ollama_payload 

475 

476 

477def convert_embed_payload_openai_to_ollama(openai_payload: dict) -> dict: 

478 """ 

479 Convert an embeddings request payload from OpenAI format to Ollama's 

480 /api/embed format, which supports batch input natively. 

481 

482 Args: 

483 openai_payload (dict): The original payload designed for OpenAI API usage. 

484 Expected keys: "model", "input" (str or list[str]). 

485 

486 Returns: 

487 dict: A payload compatible with the Ollama /api/embed endpoint. 

488 """ 

489 ollama_payload = {'model': openai_payload.get('model')} 

490 input_value = openai_payload.get('input') 

491 

492 # /api/embed accepts 'input' as a string or list of strings directly 

493 ollama_payload['input'] = input_value 

494 

495 # Optionally forward other fields if present 

496 for optional_key in ('truncate', 'options', 'keep_alive'): 

497 if optional_key in openai_payload: 

498 ollama_payload[optional_key] = openai_payload[optional_key] 

499 

500 return ollama_payload