context-compiler-example-integrations 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. context_compiler_example_integrations/README.md +71 -0
  2. context_compiler_example_integrations/__init__.py +1 -0
  3. context_compiler_example_integrations/examples/__init__.py +12 -0
  4. context_compiler_example_integrations/examples/_shared/__init__.py +1 -0
  5. context_compiler_example_integrations/examples/_shared/litellm_request.py +61 -0
  6. context_compiler_example_integrations/examples/_shared/provider_mode.py +91 -0
  7. context_compiler_example_integrations/examples/checkpoint_continuation/README.md +77 -0
  8. context_compiler_example_integrations/examples/checkpoint_continuation/example.py +169 -0
  9. context_compiler_example_integrations/examples/checkpoint_continuation/fastapi/README.md +85 -0
  10. context_compiler_example_integrations/examples/checkpoint_continuation/fastapi/app.py +205 -0
  11. context_compiler_example_integrations/examples/execution_authorization/README.md +31 -0
  12. context_compiler_example_integrations/examples/execution_authorization/expense_approval/README.md +77 -0
  13. context_compiler_example_integrations/examples/execution_authorization/expense_approval/__init__.py +1 -0
  14. context_compiler_example_integrations/examples/execution_authorization/expense_approval/example.py +168 -0
  15. context_compiler_example_integrations/examples/execution_authorization/expense_approval/fastapi/README.md +97 -0
  16. context_compiler_example_integrations/examples/execution_authorization/expense_approval/fastapi/__init__.py +1 -0
  17. context_compiler_example_integrations/examples/execution_authorization/expense_approval/fastapi/app.py +312 -0
  18. context_compiler_example_integrations/examples/execution_authorization/expense_approval/fastapi/model_approval.py +98 -0
  19. context_compiler_example_integrations/examples/gateway_middleware/README.md +49 -0
  20. context_compiler_example_integrations/examples/gateway_middleware/customer_support_routing/README.md +40 -0
  21. context_compiler_example_integrations/examples/gateway_middleware/customer_support_routing/__init__.py +1 -0
  22. context_compiler_example_integrations/examples/gateway_middleware/customer_support_routing/example.py +220 -0
  23. context_compiler_example_integrations/examples/prompt_construction/README.md +27 -0
  24. context_compiler_example_integrations/examples/prompt_construction/litellm/README.md +197 -0
  25. context_compiler_example_integrations/examples/prompt_construction/litellm/basic.py +392 -0
  26. context_compiler_example_integrations/examples/prompt_construction/litellm/confirmation_helper.py +91 -0
  27. context_compiler_example_integrations/examples/prompt_construction/litellm/with_directive_drafter.py +524 -0
  28. context_compiler_example_integrations/examples/prompt_construction/writing_assistant/README.md +92 -0
  29. context_compiler_example_integrations/examples/prompt_construction/writing_assistant/__init__.py +1 -0
  30. context_compiler_example_integrations/examples/prompt_construction/writing_assistant/example.py +205 -0
  31. context_compiler_example_integrations/examples/retrieval_filtering/README.md +65 -0
  32. context_compiler_example_integrations/examples/retrieval_filtering/chromadb_hr_policy_lookup/README.md +120 -0
  33. context_compiler_example_integrations/examples/retrieval_filtering/chromadb_hr_policy_lookup/__init__.py +1 -0
  34. context_compiler_example_integrations/examples/retrieval_filtering/chromadb_hr_policy_lookup/example.py +320 -0
  35. context_compiler_example_integrations/examples/retrieval_filtering/hr_policy_lookup/README.md +123 -0
  36. context_compiler_example_integrations/examples/retrieval_filtering/hr_policy_lookup/__init__.py +1 -0
  37. context_compiler_example_integrations/examples/retrieval_filtering/hr_policy_lookup/example.py +300 -0
  38. context_compiler_example_integrations/examples/schema_selection/README.md +41 -0
  39. context_compiler_example_integrations/examples/schema_selection/litellm_response_format/response_format.py +218 -0
  40. context_compiler_example_integrations/examples/schema_selection/ollama_structured_output/README.md +60 -0
  41. context_compiler_example_integrations/examples/schema_selection/ollama_structured_output/example.py +171 -0
  42. context_compiler_example_integrations/examples/schema_selection/refund_intake/README.md +95 -0
  43. context_compiler_example_integrations/examples/schema_selection/refund_intake/__init__.py +1 -0
  44. context_compiler_example_integrations/examples/schema_selection/refund_intake/example.py +180 -0
  45. context_compiler_example_integrations/examples/tool_gating/README.md +55 -0
  46. context_compiler_example_integrations/examples/tool_gating/calendar_admin/README.md +34 -0
  47. context_compiler_example_integrations/examples/tool_gating/calendar_admin/__init__.py +1 -0
  48. context_compiler_example_integrations/examples/tool_gating/calendar_admin/example.py +195 -0
  49. context_compiler_example_integrations/examples/tool_gating/mcp_calendar_admin/README.md +93 -0
  50. context_compiler_example_integrations/examples/tool_gating/mcp_calendar_admin/__init__.py +1 -0
  51. context_compiler_example_integrations/examples/tool_gating/mcp_calendar_admin/example.py +250 -0
  52. context_compiler_example_integrations/examples/tool_gating/mcp_calendar_admin/live_model.py +369 -0
  53. context_compiler_example_integrations/reference_integrations/__init__.py +12 -0
  54. context_compiler_example_integrations/reference_integrations/litellm_proxy/README.md +251 -0
  55. context_compiler_example_integrations/reference_integrations/litellm_proxy/_checkpoint_support.py +146 -0
  56. context_compiler_example_integrations/reference_integrations/litellm_proxy/config.example.yaml +23 -0
  57. context_compiler_example_integrations/reference_integrations/litellm_proxy/context_compiler_precall_hook.py +163 -0
  58. context_compiler_example_integrations/reference_integrations/litellm_proxy/context_compiler_precall_hook_with_directive_drafter.py +289 -0
  59. context_compiler_example_integrations/reference_integrations/openwebui_pipe/README.md +233 -0
  60. context_compiler_example_integrations/reference_integrations/openwebui_pipe/open_webui_pipe.py +750 -0
  61. context_compiler_example_integrations/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py +1045 -0
  62. context_compiler_example_integrations-0.1.0.dist-info/METADATA +110 -0
  63. context_compiler_example_integrations-0.1.0.dist-info/RECORD +65 -0
  64. context_compiler_example_integrations-0.1.0.dist-info/WHEEL +4 -0
  65. context_compiler_example_integrations-0.1.0.dist-info/licenses/LICENSE +173 -0
@@ -0,0 +1,1045 @@
1
+ """
2
+ title: Context Compiler Open WebUI Pipe (Directive Drafter)
3
+ author: rlippmann
4
+ author_url: https://github.com/rlippmann/context-compiler-example-integrations
5
+ version: 0.9.4
6
+ requirements: context-compiler>=0.8.3, context-compiler-directive-drafter>=0.1.2
7
+
8
+ Open WebUI integration with Context Compiler directive drafter.
9
+
10
+ This example extends `open_webui_pipe.py` by inserting a directive-drafting step:
11
+
12
+ 1. Run heuristic directive drafter (fast, high-precision cases)
13
+ 2. Fall back to Open WebUI-native model completion when needed
14
+ 3. Pass resulting directive (or original input) to `engine.step(...)`
15
+
16
+ Core decision handling remains the same as the base integration.
17
+ """
18
+
19
+ import inspect
20
+ import json
21
+ import logging
22
+ import re
23
+ from collections.abc import AsyncIterator
24
+ from importlib.resources import as_file, files
25
+ from importlib.resources.abc import Traversable
26
+ from typing import Any, Literal, cast
27
+
28
+ from fastapi import Request # type: ignore[import-not-found]
29
+ from open_webui.models.users import Users # type: ignore[import-not-found]
30
+ from open_webui.utils.chat import generate_chat_completion # type: ignore[import-not-found]
31
+ from open_webui.utils.models import get_all_models # type: ignore[import-not-found]
32
+
33
+ try:
34
+ from pydantic import BaseModel, Field
35
+ except ModuleNotFoundError:
36
+ # Keep this import optional: CI/tests run without integration extras.
37
+ # These lightweight fallbacks keep import-time behavior deterministic so
38
+ # coverage exercises the pipe module without pydantic installed.
39
+ class BaseModel: # type: ignore[no-redef]
40
+ def __init__(self, **kwargs: object) -> None:
41
+ for key, value in kwargs.items():
42
+ setattr(self, key, value)
43
+
44
+ def Field(*, default: Any, description: str = "") -> Any: # type: ignore[no-redef]
45
+ del description
46
+ return default
47
+
48
+
49
+ from context_compiler import (
50
+ DECISION_CLARIFY,
51
+ DECISION_PASSTHROUGH,
52
+ DECISION_UPDATE,
53
+ POLICY_PROHIBIT,
54
+ POLICY_USE,
55
+ State,
56
+ create_engine,
57
+ get_clarify_prompt,
58
+ get_decision_state,
59
+ get_policy_items,
60
+ get_premise_value,
61
+ is_clarify,
62
+ is_passthrough,
63
+ is_update,
64
+ state_diff,
65
+ )
66
+ from context_compiler.engine import Engine
67
+ from context_compiler_directive_drafter import (
68
+ PREPROCESS_OUTCOME_DIRECTIVE,
69
+ parse_preprocessor_output,
70
+ preprocess_heuristic,
71
+ render_prompt,
72
+ )
73
+
74
+ logger = logging.getLogger(__name__)
75
+
76
+ _CC_MARKER = "[[cc_state]]"
77
+ _ENGINES_BY_CHAT_KEY: dict[str, Engine] = {}
78
+ # Example-only in-memory checkpoint store.
79
+ # This keeps continuation state only for the current process lifetime.
80
+ # Real deployments should persist checkpoints externally (DB/Redis/etc.),
81
+ # or restart continuity for pending flows will be lost.
82
+ _CHECKPOINTS_BY_CHAT_KEY: dict[str, str] = {}
83
+ _PROMPTS_DIR = files("context_compiler_directive_drafter").joinpath("prompts")
84
+
85
+
86
+ def _is_directive_shaped_input(message: str) -> bool:
87
+ normalized = re.sub(r"\s+", " ", message.strip()).lower()
88
+ return (
89
+ normalized.startswith("use")
90
+ or normalized.startswith("prohibit")
91
+ or normalized.startswith("remove policy")
92
+ or normalized.startswith("set premise")
93
+ or normalized.startswith("change premise")
94
+ or normalized.startswith("clear")
95
+ or normalized.startswith("reset")
96
+ )
97
+
98
+
99
+ def _prompt_file_path(profile: str) -> Traversable:
100
+ # Runtime prompt selection for fallback drafting:
101
+ # - default: most instruction-following models
102
+ # - llama: models that need tighter prompt guidance
103
+ if profile == "llama":
104
+ return _PROMPTS_DIR.joinpath("llama.txt")
105
+ return _PROMPTS_DIR.joinpath("default.txt")
106
+
107
+
108
+ def _resolve_chat_key(
109
+ user: dict[str, Any],
110
+ chat_id: str | None,
111
+ metadata: dict[str, Any] | None,
112
+ ) -> str:
113
+ if chat_id:
114
+ return chat_id
115
+ if isinstance(metadata, dict):
116
+ metadata_chat_id = metadata.get("chat_id")
117
+ if isinstance(metadata_chat_id, str) and metadata_chat_id:
118
+ return metadata_chat_id
119
+ user_id = str(user["id"])
120
+ return f"no-chat-id:{user_id}"
121
+
122
+
123
+ def _extract_latest_user_text(messages: list[dict[str, Any]]) -> str | None:
124
+ for message in reversed(messages):
125
+ if message.get("role") != "user":
126
+ continue
127
+ content = message.get("content")
128
+ if isinstance(content, str):
129
+ return content
130
+ return None
131
+ return None
132
+
133
+
134
+ def _has_pending_clarification(engine: Engine) -> bool:
135
+ return engine.has_pending_clarification()
136
+
137
+
138
+ def _render_compiler_state_block(state: State) -> str:
139
+ lines: list[str] = [_CC_MARKER]
140
+
141
+ premise = get_premise_value(state)
142
+ if premise is not None:
143
+ lines.append(f"Premise: {premise}")
144
+
145
+ use_items = sorted(get_policy_items(state, POLICY_USE))
146
+ if use_items:
147
+ lines.append("Use: " + ", ".join(use_items))
148
+
149
+ prohibit_items = sorted(get_policy_items(state, POLICY_PROHIBIT))
150
+ if prohibit_items:
151
+ lines.append("Prohibit: " + ", ".join(prohibit_items))
152
+
153
+ return "\n".join(lines)
154
+
155
+
156
+ def _render_show_state_summary(engine: Engine) -> str:
157
+ premise = get_premise_value(engine.state)
158
+ use_items = sorted(get_policy_items(engine.state, POLICY_USE))
159
+ prohibit_items = sorted(get_policy_items(engine.state, POLICY_PROHIBIT))
160
+ pending = engine.has_pending_clarification()
161
+
162
+ use_text = ", ".join(use_items) if use_items else "none"
163
+ prohibit_text = ", ".join(prohibit_items) if prohibit_items else "none"
164
+ premise_text = premise if premise is not None else "none"
165
+ pending_text = "yes" if pending else "no"
166
+
167
+ return (
168
+ f"Premise: {premise_text}\n"
169
+ f"Use: {use_text}\n"
170
+ f"Prohibit: {prohibit_text}\n"
171
+ f"Pending clarification: {pending_text}"
172
+ )
173
+
174
+
175
+ def _replace_compiler_system_message(
176
+ messages: list[dict[str, Any]],
177
+ rendered_state_block: str,
178
+ ) -> list[dict[str, Any]]:
179
+ filtered_messages: list[dict[str, Any]] = []
180
+ last_system_index = -1
181
+
182
+ for message in messages:
183
+ role = message.get("role")
184
+ content = message.get("content")
185
+ if (
186
+ role == "system"
187
+ and isinstance(content, str)
188
+ and content.startswith(_CC_MARKER)
189
+ ):
190
+ continue
191
+
192
+ filtered_messages.append(message)
193
+ if role == "system":
194
+ last_system_index = len(filtered_messages) - 1
195
+
196
+ insert_at = last_system_index + 1 if last_system_index >= 0 else 0
197
+ compiler_message: dict[str, Any] = {
198
+ "role": "system",
199
+ "content": rendered_state_block,
200
+ }
201
+ return [
202
+ *filtered_messages[:insert_at],
203
+ compiler_message,
204
+ *filtered_messages[insert_at:],
205
+ ]
206
+
207
+
208
+ def _normalize_state(value: object) -> State:
209
+ if isinstance(value, dict):
210
+ return cast(State, value)
211
+ return {"premise": None, "policies": {}, "version": 2}
212
+
213
+
214
+ def _has_non_empty_authoritative_state(state: State) -> bool:
215
+ if get_premise_value(state) is not None:
216
+ return True
217
+ return bool(
218
+ get_policy_items(state, POLICY_USE) or get_policy_items(state, POLICY_PROHIBIT)
219
+ )
220
+
221
+
222
+ def _render_state_summary_line(state: object) -> str:
223
+ if not isinstance(state, dict):
224
+ return "unavailable"
225
+ typed_state = cast(State, state)
226
+
227
+ premise = get_premise_value(typed_state)
228
+ use_items = sorted(get_policy_items(typed_state, POLICY_USE))
229
+ prohibit_items = sorted(get_policy_items(typed_state, POLICY_PROHIBIT))
230
+ return (
231
+ f"premise={premise if premise is not None else '(none)'}; "
232
+ f"use={', '.join(use_items) if use_items else '(none)'}; "
233
+ f"prohibit={', '.join(prohibit_items) if prohibit_items else '(none)'}"
234
+ )
235
+
236
+
237
+ def _build_compact_trace_text(
238
+ *,
239
+ decision: object,
240
+ state_before: object,
241
+ state_after: object,
242
+ llm_called: bool,
243
+ state_injected: str,
244
+ ) -> str:
245
+ kind = decision.get("kind", "unknown") if isinstance(decision, dict) else "unknown"
246
+ changed = "unknown"
247
+ if isinstance(state_before, dict) and isinstance(state_after, dict):
248
+ changed = (
249
+ "yes"
250
+ if state_diff(cast(State, state_before), cast(State, state_after))[
251
+ "changed"
252
+ ]
253
+ else "no"
254
+ )
255
+ return "\n".join(
256
+ [
257
+ "Context Compiler trace",
258
+ f"- decision: {kind}",
259
+ f"- llm_called: {'yes' if llm_called else 'no'}",
260
+ f"- state_changed: {changed}",
261
+ f"- state_injected: {state_injected}",
262
+ f"- state_before: {_render_state_summary_line(state_before)}",
263
+ f"- state_after: {_render_state_summary_line(state_after)}",
264
+ ]
265
+ )
266
+
267
+
268
+ def _strip_trace_block_from_text(content: str) -> str:
269
+ marker = "Context Compiler trace"
270
+ index = content.find(marker)
271
+ if index < 0:
272
+ return content
273
+ return content[:index].rstrip()
274
+
275
+
276
+ def _strip_trace_blocks_from_messages(
277
+ messages: list[dict[str, Any]],
278
+ ) -> list[dict[str, Any]]:
279
+ cleaned: list[dict[str, Any]] = []
280
+ for message in messages:
281
+ msg = dict(message)
282
+ content = msg.get("content")
283
+ if isinstance(content, str):
284
+ msg["content"] = _strip_trace_block_from_text(content)
285
+ cleaned.append(msg)
286
+ return cleaned
287
+
288
+
289
+ def _build_forward_messages(
290
+ raw_messages: object,
291
+ *,
292
+ state: State | None = None,
293
+ ) -> list[dict[str, Any]]:
294
+ """Build forwarded messages with trace stripping and optional state injection."""
295
+ messages = (
296
+ _strip_trace_blocks_from_messages(
297
+ [msg for msg in raw_messages if isinstance(msg, dict)]
298
+ )
299
+ if isinstance(raw_messages, list)
300
+ else []
301
+ )
302
+ if state is not None and _has_non_empty_authoritative_state(state):
303
+ return _replace_compiler_system_message(
304
+ messages,
305
+ _render_compiler_state_block(state),
306
+ )
307
+ return messages
308
+
309
+
310
+ def _strip_existing_trace_from_chunk(chunk: object) -> object:
311
+ if isinstance(chunk, str):
312
+ return _strip_trace_block_from_text(chunk)
313
+ if isinstance(chunk, bytes):
314
+ decoded = chunk.decode("utf-8", errors="ignore")
315
+ cleaned = _strip_trace_block_from_text(decoded)
316
+ return cleaned.encode("utf-8")
317
+ return chunk
318
+
319
+
320
+ def _render_item_label(value: str) -> str:
321
+ return re.sub(r"\s+", " ", value).strip().lower()
322
+
323
+
324
+ def _near_miss_directive_clarify(value: str) -> str | None:
325
+ normalized = re.sub(r"\s+", " ", value.strip())
326
+ lower = normalized.lower()
327
+
328
+ if lower in {"reset premise", "reset premises", "clear premises"}:
329
+ return "Unknown directive.\nUse 'clear premise' or 'reset policies'."
330
+ if lower.startswith("set premise to "):
331
+ return "Invalid premise syntax.\nUse 'set premise <value>'."
332
+ if lower.startswith("change premise ") and not lower.startswith(
333
+ "change premise to "
334
+ ):
335
+ return "Invalid premise syntax.\nUse 'change premise to <value>'."
336
+ return None
337
+
338
+
339
+ def _summarize_update_from_input(user_input: str) -> str:
340
+ normalized = re.sub(r"\s+", " ", user_input.strip())
341
+ lower = normalized.lower()
342
+
343
+ if lower == "clear state":
344
+ return "State cleared."
345
+ if lower == "clear premise":
346
+ return "Premise cleared."
347
+ if lower == "reset policies":
348
+ return "Policies reset."
349
+
350
+ replacement_match = re.match(
351
+ r"^use\s+(.+?)\s+instead\s+of\s+(.+)$", normalized, flags=re.IGNORECASE
352
+ )
353
+ if replacement_match is not None:
354
+ item = _render_item_label(replacement_match.group(1).rstrip(" .!?"))
355
+ if item:
356
+ return f"State updated: Use {item}."
357
+
358
+ use_match = re.match(r"^use\s+(.+)$", normalized, flags=re.IGNORECASE)
359
+ if use_match is not None:
360
+ item = _render_item_label(use_match.group(1).rstrip(" .!?"))
361
+ if item:
362
+ return f"State updated: Use {item}."
363
+
364
+ prohibit_match = re.match(r"^prohibit\s+(.+)$", normalized, flags=re.IGNORECASE)
365
+ if prohibit_match is not None:
366
+ item = _render_item_label(prohibit_match.group(1).rstrip(" .!?"))
367
+ if item:
368
+ return f"State updated: Prohibit {item}."
369
+
370
+ remove_policy_match = re.match(
371
+ r"^remove\s+policy\s+(.+)$", normalized, flags=re.IGNORECASE
372
+ )
373
+ if remove_policy_match is not None:
374
+ item = _render_item_label(remove_policy_match.group(1).rstrip(" .!?"))
375
+ if item:
376
+ return f"State updated: Removed policy {item}."
377
+
378
+ return "State updated."
379
+
380
+
381
+ def _is_administrative_update_input(user_input: str) -> bool:
382
+ normalized = re.sub(r"\s+", " ", user_input.strip()).lower()
383
+ return (
384
+ normalized == "clear state"
385
+ or normalized == "clear premise"
386
+ or normalized == "reset policies"
387
+ or normalized.startswith("remove policy ")
388
+ )
389
+
390
+
391
+ def _extract_completion_content(response: object) -> str | None:
392
+ choices_attr = getattr(response, "choices", None)
393
+ if isinstance(choices_attr, list) and choices_attr:
394
+ first_choice = choices_attr[0]
395
+ message_attr = getattr(first_choice, "message", None)
396
+ content_attr = getattr(message_attr, "content", None)
397
+ if isinstance(content_attr, str):
398
+ return content_attr
399
+
400
+ if isinstance(response, dict):
401
+ choices = response.get("choices")
402
+ if isinstance(choices, list) and choices:
403
+ first_choice = choices[0]
404
+ if isinstance(first_choice, dict):
405
+ message = first_choice.get("message")
406
+ if isinstance(message, dict):
407
+ content = message.get("content")
408
+ if isinstance(content, str):
409
+ return content
410
+
411
+ return None
412
+
413
+
414
+ def _normalize_model_id(value: str | None) -> str | None:
415
+ if value is None:
416
+ return None
417
+ value = value.strip()
418
+ return value or None
419
+
420
+
421
+ def _is_truthy_bool(value: object) -> bool:
422
+ if isinstance(value, bool):
423
+ return value
424
+ if isinstance(value, str):
425
+ normalized = value.strip().lower()
426
+ if normalized in {"true", "1", "on"}:
427
+ return True
428
+ if normalized in {"false", "0", "off"}:
429
+ return False
430
+ return False
431
+
432
+
433
+ class Pipe:
434
+ """Map Context Compiler decisions into Open WebUI pipe behavior.
435
+
436
+ This variant adds a directive-drafter stage before ``engine.step(...)``:
437
+ heuristic first, then Open WebUI-native LLM fallback.
438
+ Update decisions return deterministic local acknowledgement (no model call).
439
+ """
440
+
441
+ class Valves(BaseModel):
442
+ BASE_MODEL_ID: str = Field(
443
+ default="",
444
+ description=(
445
+ "Required Open WebUI model id used for forwarding. Must exactly match a "
446
+ "configured model id in Open WebUI (not arbitrary text), for example: "
447
+ "llama3.1:8b."
448
+ ),
449
+ )
450
+ PREPROCESSOR_MODEL_ID: str | None = Field(
451
+ default=None,
452
+ description=(
453
+ "Optional model id for fallback drafting (defaults to BASE_MODEL_ID)."
454
+ ),
455
+ )
456
+ PREPROCESSOR_PROMPT_PROFILE: Literal["default", "llama"] = Field(
457
+ default="default",
458
+ description="Prompt profile for LLM fallback drafting.",
459
+ )
460
+ ALLOW_MISSING_BASE_MODEL_FOR_DEBUG: bool = Field(
461
+ default=False,
462
+ description="Allow missing BASE_MODEL_ID for debug/testing only.",
463
+ )
464
+ SHOW_CONTEXT_COMPILER_TRACE: bool = Field(
465
+ default=False,
466
+ description="Include concise Context Compiler trace text in responses.",
467
+ )
468
+
469
+ def __init__(self) -> None:
470
+ self.valves = self.Valves()
471
+
472
+ def _allow_missing_base_model_for_debug(self) -> bool:
473
+ return _is_truthy_bool(
474
+ getattr(self.valves, "ALLOW_MISSING_BASE_MODEL_FOR_DEBUG", False)
475
+ )
476
+
477
+ def _trace_enabled(self) -> bool:
478
+ return bool(getattr(self.valves, "SHOW_CONTEXT_COMPILER_TRACE", False))
479
+
480
+ def _append_trace_to_response(self, response: Any, trace_text: str) -> Any:
481
+ body_iterator = getattr(response, "body_iterator", None)
482
+ if body_iterator is not None and callable(
483
+ getattr(body_iterator, "__aiter__", None)
484
+ ):
485
+ response.body_iterator = self._append_trace_to_stream(
486
+ cast(AsyncIterator[object], body_iterator), trace_text
487
+ )
488
+ return response
489
+ aiter = getattr(response, "__aiter__", None)
490
+ if callable(aiter):
491
+ return self._append_trace_to_stream(
492
+ cast(AsyncIterator[object], response), trace_text
493
+ )
494
+ if isinstance(response, str):
495
+ cleaned = _strip_trace_block_from_text(response)
496
+ return f"{cleaned}\n\n{trace_text}"
497
+ if isinstance(response, dict):
498
+ choices = response.get("choices")
499
+ if isinstance(choices, list) and choices:
500
+ first = choices[0]
501
+ if isinstance(first, dict):
502
+ message = first.get("message")
503
+ if isinstance(message, dict):
504
+ content = message.get("content")
505
+ if isinstance(content, str):
506
+ cleaned = _strip_trace_block_from_text(content)
507
+ message["content"] = f"{cleaned}\n\n{trace_text}"
508
+ return response
509
+ choices_attr = getattr(response, "choices", None)
510
+ if isinstance(choices_attr, list) and choices_attr:
511
+ first_choice = choices_attr[0]
512
+ message_attr = getattr(first_choice, "message", None)
513
+ content_attr = getattr(message_attr, "content", None)
514
+ if message_attr is not None and isinstance(content_attr, str):
515
+ cleaned = _strip_trace_block_from_text(content_attr)
516
+ message_attr.content = f"{cleaned}\n\n{trace_text}"
517
+ return response
518
+ return response
519
+
520
+ def _append_trace_to_stream(
521
+ self, stream: AsyncIterator[object], trace_text: str
522
+ ) -> AsyncIterator[object]:
523
+ async def _wrapped() -> AsyncIterator[object]:
524
+ chunk_type: type[str] | type[bytes] | None = None
525
+ saw_done = False
526
+ trace_json = json.dumps(
527
+ {"choices": [{"delta": {"content": f"\n\n{trace_text}"}}]}
528
+ )
529
+ trace_event = f"data: {trace_json}\n\n"
530
+
531
+ def _matches_done(value: str) -> bool:
532
+ normalized = value.strip()
533
+ return normalized == "data: [DONE]" or normalized == "[DONE]"
534
+
535
+ async for chunk in stream:
536
+ if chunk_type is None:
537
+ if isinstance(chunk, bytes):
538
+ chunk_type = bytes
539
+ elif isinstance(chunk, str):
540
+ chunk_type = str
541
+ if isinstance(chunk, bytes):
542
+ decoded = chunk.decode("utf-8", errors="ignore")
543
+ if _matches_done(decoded):
544
+ saw_done = True
545
+ yield trace_event.encode("utf-8")
546
+ yield chunk
547
+ continue
548
+ elif isinstance(chunk, str) and _matches_done(chunk):
549
+ saw_done = True
550
+ yield trace_event
551
+ yield chunk
552
+ continue
553
+ yield _strip_existing_trace_from_chunk(chunk)
554
+ if saw_done:
555
+ return
556
+ suffix = f"\n\n{trace_text}"
557
+ if chunk_type is bytes:
558
+ yield suffix.encode("utf-8")
559
+ else:
560
+ yield suffix
561
+
562
+ return _wrapped()
563
+
564
+ def _with_trace(
565
+ self,
566
+ response: Any,
567
+ *,
568
+ original_input: str,
569
+ compiler_input: str,
570
+ decision: object,
571
+ state_before: object,
572
+ state_after: object,
573
+ llm_called: bool,
574
+ preprocessor_output: str | None = None,
575
+ state_injected: str = "no",
576
+ ) -> Any:
577
+ if not self._trace_enabled():
578
+ return response
579
+ del original_input, compiler_input, preprocessor_output
580
+ trace_text = _build_compact_trace_text(
581
+ decision=decision,
582
+ state_before=state_before,
583
+ state_after=state_after,
584
+ llm_called=llm_called,
585
+ state_injected=state_injected,
586
+ )
587
+ return self._append_trace_to_response(response, trace_text)
588
+
589
+ def _is_model_not_found_text(self, value: object) -> bool:
590
+ if not isinstance(value, str):
591
+ return False
592
+ return "model not found" in value.lower()
593
+
594
+ def _contains_model_not_found(self, value: object) -> bool:
595
+ if self._is_model_not_found_text(value):
596
+ return True
597
+ if isinstance(value, dict):
598
+ return any(self._contains_model_not_found(v) for v in value.values())
599
+ if isinstance(value, list):
600
+ return any(self._contains_model_not_found(v) for v in value)
601
+ return False
602
+
603
+ def _normalize_forward_error(self, response: Any) -> str | None:
604
+ if self._contains_model_not_found(response):
605
+ return (
606
+ "Context Compiler pipe misconfigured: BASE_MODEL_ID is invalid or not "
607
+ "configured in Open WebUI. Configure a valid model id in "
608
+ "Admin Panel → Settings → Models."
609
+ )
610
+ return None
611
+
612
+ def _normalize_forward_exception(self, exc: Exception) -> str | None:
613
+ detail = getattr(exc, "detail", None)
614
+ if self._contains_model_not_found(detail) or self._contains_model_not_found(
615
+ str(exc)
616
+ ):
617
+ return (
618
+ "Context Compiler pipe misconfigured: BASE_MODEL_ID is invalid or not "
619
+ "configured in Open WebUI. Configure a valid model id in "
620
+ "Admin Panel → Settings → Models."
621
+ )
622
+ return None
623
+
624
+ def _normalize_preprocessor_error(self, response: Any) -> str | None:
625
+ if self._contains_model_not_found(response):
626
+ return (
627
+ "Context Compiler pipe misconfigured: PREPROCESSOR_MODEL_ID is invalid or "
628
+ "not configured in Open WebUI. Configure a valid model id in "
629
+ "Admin Panel → Settings → Models."
630
+ )
631
+ return None
632
+
633
+ def _normalize_preprocessor_exception(self, exc: Exception) -> str | None:
634
+ detail = getattr(exc, "detail", None)
635
+ if self._contains_model_not_found(detail) or self._contains_model_not_found(
636
+ str(exc)
637
+ ):
638
+ return (
639
+ "Context Compiler pipe misconfigured: PREPROCESSOR_MODEL_ID is invalid or "
640
+ "not configured in Open WebUI. Configure a valid model id in "
641
+ "Admin Panel → Settings → Models."
642
+ )
643
+ return None
644
+
645
+ def _resolve_preprocessor_model_id(self, base_model_id: str | None) -> str | None:
646
+ preprocessor_model_id = _normalize_model_id(self.valves.PREPROCESSOR_MODEL_ID)
647
+ return preprocessor_model_id or base_model_id
648
+
649
+ async def _validate_configured_model_ids(
650
+ self,
651
+ request: Request,
652
+ user_payload: dict[str, Any],
653
+ *,
654
+ base_model_id: str | None,
655
+ preprocessor_model_id: str | None,
656
+ ) -> str | None:
657
+ base_model_id = _normalize_model_id(base_model_id)
658
+ preprocessor_model_id = _normalize_model_id(preprocessor_model_id)
659
+ # Best-effort preflight: fail closed only for clear missing-model mismatches.
660
+ # If model discovery fails, preserve runtime behavior and rely on call-path
661
+ # normalization below.
662
+ user = Users.get_user_by_id(user_payload["id"])
663
+ if inspect.isawaitable(user):
664
+ user = await user
665
+ try:
666
+ models = await get_all_models(request, user=user)
667
+ except Exception:
668
+ return None
669
+
670
+ known_model_ids: set[str] = set()
671
+ if isinstance(models, list):
672
+ for model in models:
673
+ if not isinstance(model, dict):
674
+ continue
675
+ model_id = model.get("id")
676
+ if isinstance(model_id, str):
677
+ known_model_ids.add(model_id)
678
+
679
+ if base_model_id and base_model_id not in known_model_ids:
680
+ return (
681
+ "Context Compiler pipe misconfigured: BASE_MODEL_ID was not found "
682
+ "in Open WebUI models."
683
+ )
684
+ if preprocessor_model_id and preprocessor_model_id not in known_model_ids:
685
+ return (
686
+ "Context Compiler pipe misconfigured: PREPROCESSOR_MODEL_ID was not found "
687
+ "in Open WebUI models."
688
+ )
689
+ return None
690
+
691
+ async def _llm_fallback_preprocess(
692
+ self,
693
+ message: str,
694
+ state: State,
695
+ *,
696
+ request: Request,
697
+ user_payload: dict[str, Any],
698
+ prompt_profile: str,
699
+ model_id: str | None,
700
+ ) -> tuple[str | None, str | None]:
701
+ model_id = _normalize_model_id(model_id)
702
+ if model_id is None:
703
+ return None, None
704
+ with as_file(_prompt_file_path(prompt_profile)) as prompt_path:
705
+ prompt = render_prompt(prompt_path, state)
706
+ if prompt is None:
707
+ return None, None
708
+
709
+ payload: dict[str, Any] = {
710
+ "model": model_id,
711
+ "stream": False,
712
+ "messages": [
713
+ {"role": "system", "content": prompt},
714
+ {"role": "user", "content": message},
715
+ ],
716
+ }
717
+ user = Users.get_user_by_id(user_payload["id"])
718
+ if inspect.isawaitable(user):
719
+ user = await user
720
+ try:
721
+ response = await generate_chat_completion(request, payload, user)
722
+ except Exception as exc:
723
+ normalized_exception = self._normalize_preprocessor_exception(exc)
724
+ if normalized_exception is not None:
725
+ return None, normalized_exception
726
+ return None, None
727
+
728
+ normalized_error = self._normalize_preprocessor_error(response)
729
+ if normalized_error is not None:
730
+ return None, normalized_error
731
+
732
+ raw_output = _extract_completion_content(response)
733
+ parsed = parse_preprocessor_output(raw_output)
734
+ if parsed is None:
735
+ return None, None
736
+ return parsed, None
737
+
738
+ async def _preprocess_user_input(
739
+ self,
740
+ message: str,
741
+ state: State,
742
+ *,
743
+ request: Request,
744
+ user_payload: dict[str, Any],
745
+ prompt_profile: str,
746
+ model_id: str | None,
747
+ ) -> tuple[str | None, str | None]:
748
+ # Heuristic first for precision, determinism, and low latency.
749
+ # If heuristic does not produce a directive, try Open WebUI-native fallback.
750
+ heuristic_result = preprocess_heuristic(message)
751
+
752
+ if (
753
+ heuristic_result["outcome"] == PREPROCESS_OUTCOME_DIRECTIVE
754
+ and heuristic_result["directive"]
755
+ ):
756
+ parsed = parse_preprocessor_output(heuristic_result["directive"])
757
+ if parsed is not None:
758
+ return parsed, None
759
+
760
+ if _is_directive_shaped_input(message):
761
+ return None, None
762
+
763
+ # In debug mode with missing base/preprocessor model ids, skip fallback
764
+ # preprocess entirely so we never attempt an empty-model LLM call.
765
+ model_id = _normalize_model_id(model_id)
766
+ if model_id is None:
767
+ return None, None
768
+
769
+ return await self._llm_fallback_preprocess(
770
+ message,
771
+ state,
772
+ request=request,
773
+ user_payload=user_payload,
774
+ prompt_profile=prompt_profile,
775
+ model_id=model_id,
776
+ )
777
+
778
+ async def _forward_passthrough(
779
+ self,
780
+ body: dict[str, Any],
781
+ user_payload: dict[str, Any],
782
+ request: Request,
783
+ *,
784
+ base_model_id: str | None,
785
+ state: State | None = None,
786
+ ) -> Any:
787
+ if base_model_id is None:
788
+ if self._allow_missing_base_model_for_debug():
789
+ return (
790
+ "Context Compiler debug mode: BASE_MODEL_ID is empty; "
791
+ "skipping model passthrough."
792
+ )
793
+ return (
794
+ "Context Compiler pipe misconfigured: BASE_MODEL_ID is required "
795
+ "(or set ALLOW_MISSING_BASE_MODEL_FOR_DEBUG=true for testing)."
796
+ )
797
+ payload = {**body}
798
+ payload["model"] = base_model_id
799
+ payload["messages"] = _build_forward_messages(body.get("messages"), state=state)
800
+ user = Users.get_user_by_id(user_payload["id"])
801
+ if inspect.isawaitable(user):
802
+ user = await user
803
+ try:
804
+ response = await generate_chat_completion(request, payload, user)
805
+ except Exception as exc:
806
+ normalized_exception = self._normalize_forward_exception(exc)
807
+ if normalized_exception is not None:
808
+ return normalized_exception
809
+ raise
810
+ normalized_error = self._normalize_forward_error(response)
811
+ if normalized_error is not None:
812
+ return normalized_error
813
+ return response
814
+
815
+ async def _forward_update(
816
+ self,
817
+ body: dict[str, Any],
818
+ user_payload: dict[str, Any],
819
+ request: Request,
820
+ state: State,
821
+ *,
822
+ base_model_id: str | None,
823
+ ) -> Any:
824
+ if base_model_id is None:
825
+ if self._allow_missing_base_model_for_debug():
826
+ return (
827
+ "Context Compiler debug mode: BASE_MODEL_ID is empty; "
828
+ "skipping model passthrough."
829
+ )
830
+ return (
831
+ "Context Compiler pipe misconfigured: BASE_MODEL_ID is required "
832
+ "(or set ALLOW_MISSING_BASE_MODEL_FOR_DEBUG=true for testing)."
833
+ )
834
+ payload = {**body}
835
+ payload["model"] = base_model_id
836
+
837
+ payload["messages"] = _build_forward_messages(body.get("messages"), state=state)
838
+
839
+ user = Users.get_user_by_id(user_payload["id"])
840
+ if inspect.isawaitable(user):
841
+ user = await user
842
+ try:
843
+ response = await generate_chat_completion(request, payload, user)
844
+ except Exception as exc:
845
+ normalized_exception = self._normalize_forward_exception(exc)
846
+ if normalized_exception is not None:
847
+ return normalized_exception
848
+ raise
849
+ normalized_error = self._normalize_forward_error(response)
850
+ if normalized_error is not None:
851
+ return normalized_error
852
+ return response
853
+
854
+ async def pipe(
855
+ self,
856
+ body: dict[str, Any],
857
+ __user__: dict[str, Any],
858
+ __request__: Request,
859
+ __chat_id__: str | None = None,
860
+ __metadata__: dict[str, Any] | None = None,
861
+ ) -> Any:
862
+ # Open WebUI integration entrypoint:
863
+ # 1) extract latest user input
864
+ # 2) run preprocess (heuristic -> LLM fallback)
865
+ # 3) pass directive or original input to engine.step(...)
866
+ # 4) map decision back to Open WebUI response behavior
867
+ raw_messages = body.get("messages")
868
+ messages = (
869
+ [msg for msg in raw_messages if isinstance(msg, dict)]
870
+ if isinstance(raw_messages, list)
871
+ else []
872
+ )
873
+ base_model_id = _normalize_model_id(self.valves.BASE_MODEL_ID)
874
+ preprocessor_model_id = _normalize_model_id(self.valves.PREPROCESSOR_MODEL_ID)
875
+ effective_preprocessor_model = preprocessor_model_id or base_model_id
876
+ current_model_id = str(body.get("model", "")).strip()
877
+
878
+ if not base_model_id and not self._allow_missing_base_model_for_debug():
879
+ return (
880
+ "Context Compiler pipe misconfigured: BASE_MODEL_ID is required "
881
+ "(or set ALLOW_MISSING_BASE_MODEL_FOR_DEBUG=true for testing)."
882
+ )
883
+ if base_model_id and current_model_id and base_model_id == current_model_id:
884
+ return (
885
+ "Context Compiler pipe misconfigured: BASE_MODEL_ID must not match "
886
+ "the selected pipe model id to avoid recursive routing."
887
+ )
888
+ if (
889
+ effective_preprocessor_model
890
+ and current_model_id
891
+ and effective_preprocessor_model == current_model_id
892
+ ):
893
+ return (
894
+ "Context Compiler pipe misconfigured: PREPROCESSOR_MODEL_ID must not "
895
+ "match the selected pipe model id to avoid recursive routing."
896
+ )
897
+
898
+ preflight_error = await self._validate_configured_model_ids(
899
+ __request__,
900
+ __user__,
901
+ base_model_id=base_model_id,
902
+ preprocessor_model_id=effective_preprocessor_model,
903
+ )
904
+ if preflight_error is not None:
905
+ return preflight_error
906
+
907
+ latest_user_text = _extract_latest_user_text(messages)
908
+ logger.debug("preprocessor: user_input_found=%s", latest_user_text is not None)
909
+
910
+ if latest_user_text is None:
911
+ return await self._forward_passthrough(
912
+ body,
913
+ __user__,
914
+ __request__,
915
+ base_model_id=base_model_id,
916
+ )
917
+
918
+ chat_key = _resolve_chat_key(__user__, __chat_id__, __metadata__)
919
+ engine = _ENGINES_BY_CHAT_KEY.get(chat_key)
920
+ if engine is None:
921
+ engine = create_engine()
922
+ checkpoint = _CHECKPOINTS_BY_CHAT_KEY.get(chat_key)
923
+ if checkpoint is not None:
924
+ engine.import_checkpoint_json(checkpoint)
925
+ _ENGINES_BY_CHAT_KEY[chat_key] = engine
926
+
927
+ if latest_user_text.strip().lower() == "show state":
928
+ return _render_show_state_summary(engine)
929
+
930
+ state_before = engine.state
931
+
932
+ preprocessd: str | None = None
933
+ preprocess_error: str | None = None
934
+ if not _has_pending_clarification(engine):
935
+ preprocessd, preprocess_error = await self._preprocess_user_input(
936
+ latest_user_text,
937
+ engine.state,
938
+ request=__request__,
939
+ user_payload=__user__,
940
+ prompt_profile=self.valves.PREPROCESSOR_PROMPT_PROFILE,
941
+ model_id=effective_preprocessor_model,
942
+ )
943
+ if preprocess_error is not None:
944
+ return preprocess_error
945
+
946
+ logger.debug("preprocessor: preprocessd=%r", preprocessd)
947
+ # Preserve core behavior: if preprocess yields no directive, use raw user
948
+ # text so the compiler still decides clarify/passthrough/update.
949
+ compile_input = preprocessd if preprocessd is not None else latest_user_text
950
+
951
+ logger.debug("preprocessor: engine_input=%r", compile_input)
952
+ decision = engine.step(compile_input)
953
+ if is_clarify(decision):
954
+ kind = DECISION_CLARIFY
955
+ elif is_update(decision):
956
+ kind = DECISION_UPDATE
957
+ else:
958
+ kind = DECISION_PASSTHROUGH
959
+ logger.debug("preprocessor: decision=%s", kind)
960
+ near_miss_prompt = _near_miss_directive_clarify(latest_user_text)
961
+ state_after = get_decision_state(decision)
962
+ if state_after is None:
963
+ state_after = engine.state
964
+
965
+ if is_clarify(decision):
966
+ _CHECKPOINTS_BY_CHAT_KEY[chat_key] = engine.export_checkpoint_json()
967
+ return self._with_trace(
968
+ near_miss_prompt or get_clarify_prompt(decision) or "",
969
+ original_input=latest_user_text,
970
+ compiler_input=compile_input,
971
+ decision=decision,
972
+ state_before=state_before,
973
+ state_after=state_after,
974
+ preprocessor_output=preprocessd,
975
+ llm_called=False,
976
+ )
977
+ if near_miss_prompt is not None and is_passthrough(decision):
978
+ return self._with_trace(
979
+ near_miss_prompt,
980
+ original_input=latest_user_text,
981
+ compiler_input=compile_input,
982
+ decision={"kind": DECISION_CLARIFY, "prompt_to_user": near_miss_prompt},
983
+ state_before=state_before,
984
+ state_after=state_after,
985
+ preprocessor_output=preprocessd,
986
+ llm_called=False,
987
+ )
988
+ if is_passthrough(decision):
989
+ compiled_state = _normalize_state(state_after)
990
+ state_injected = (
991
+ "yes" if _has_non_empty_authoritative_state(compiled_state) else "no"
992
+ )
993
+ response = await self._forward_passthrough(
994
+ body,
995
+ __user__,
996
+ __request__,
997
+ base_model_id=base_model_id,
998
+ state=compiled_state,
999
+ )
1000
+ return self._with_trace(
1001
+ response,
1002
+ original_input=latest_user_text,
1003
+ compiler_input=compile_input,
1004
+ decision=decision,
1005
+ state_before=state_before,
1006
+ state_after=state_after,
1007
+ preprocessor_output=preprocessd,
1008
+ llm_called=base_model_id is not None,
1009
+ state_injected=state_injected,
1010
+ )
1011
+ if is_update(decision):
1012
+ _CHECKPOINTS_BY_CHAT_KEY[chat_key] = engine.export_checkpoint_json()
1013
+ return self._with_trace(
1014
+ _summarize_update_from_input(compile_input),
1015
+ original_input=latest_user_text,
1016
+ compiler_input=compile_input,
1017
+ decision=decision,
1018
+ state_before=state_before,
1019
+ state_after=state_after,
1020
+ preprocessor_output=preprocessd,
1021
+ llm_called=False,
1022
+ )
1023
+
1024
+ compiled_state = _normalize_state(state_after)
1025
+ state_injected = (
1026
+ "yes" if _has_non_empty_authoritative_state(compiled_state) else "no"
1027
+ )
1028
+ response = await self._forward_passthrough(
1029
+ body,
1030
+ __user__,
1031
+ __request__,
1032
+ base_model_id=base_model_id,
1033
+ state=compiled_state,
1034
+ )
1035
+ return self._with_trace(
1036
+ response,
1037
+ original_input=latest_user_text,
1038
+ compiler_input=compile_input,
1039
+ decision=decision,
1040
+ state_before=state_before,
1041
+ state_after=state_after,
1042
+ preprocessor_output=preprocessd,
1043
+ llm_called=base_model_id is not None,
1044
+ state_injected=state_injected,
1045
+ )