Skip to content

Commit 80e5ffd

Browse files
committed
feat: add litellm response_format example
1 parent 93a2dd8 commit 80e5ffd

5 files changed

Lines changed: 376 additions & 2 deletions

File tree

examples/integrations/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ These examples show how to use Context Compiler inside external app runtimes.
44

55
## LiteLLM (SDK)
66

7-
Minimal example showing how to run Context Compiler before sending a request to the LLM with LiteLLM.
7+
Minimal examples showing how to run Context Compiler before sending a request to the LLM with LiteLLM.
88

99
Files:
1010
- Examples (basic + preprocessor): [litellm/README.md](litellm/README.md)
@@ -30,6 +30,7 @@ See the LiteLLM examples README for setup and usage:
3030
- If result is `clarify`, show the question and do not call the LLM.
3131
- If result is `passthrough`, send normal user input.
3232
- If result is `update`, use updated state and call the model with saved state in the prompt.
33+
- `response_format.py` shows a different boundary: saved compiler state changes the LiteLLM request shape instead of being reinjected into prompt text.
3334

3435
## LiteLLM Proxy
3536

examples/integrations/litellm/README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# LiteLLM examples
22

3-
This directory contains two small Context Compiler + LiteLLM integration examples:
3+
This directory contains three small Context Compiler + LiteLLM integration examples:
44

55
- `basic.py`: compiler-only flow (no preprocessor)
6+
- `response_format.py`: host-side LiteLLM `response_format` selection from saved compiler state
67
- `with_preprocessor.py`: heuristic-first preprocessor with optional LLM fallback before `engine.step(...)`
78

89
## Requirements
@@ -50,6 +51,21 @@ PY
5051

5152
This near-miss input should return `clarify` instead of being rewritten.
5253

54+
For host-side response shape selection:
55+
56+
```shell
57+
pip install "context-compiler[integrations]"
58+
export OPENAI_API_KEY=...
59+
export MODEL=openai/gpt-4o-mini
60+
python - <<'PY'
61+
from context_compiler import create_engine
62+
from examples.integrations.litellm.response_format import plan_turn
63+
engine = create_engine()
64+
engine.step("use compact_summary")
65+
print(plan_turn("Summarize the release notes.", engine))
66+
PY
67+
```
68+
5369
## Environment configuration
5470

5571
Required (normal `openai` mode):
@@ -122,6 +138,18 @@ Note: In these LiteLLM examples, `update` is rendered locally and does not call
122138
the downstream LLM. This makes state changes explicit. Production apps may
123139
choose different rendering behavior.
124140

141+
## Response format example boundary
142+
143+
`response_format.py` shows a different integration boundary from prompt reinjection:
144+
145+
- Context Compiler owns authoritative state.
146+
- The host reads saved policy state and selects a LiteLLM `response_format` or omits it.
147+
- LiteLLM owns model invocation and provider behavior.
148+
- Context Compiler does not call LiteLLM on its own.
149+
- Context Compiler does not validate model output.
150+
- Context Compiler does not generate schemas dynamically.
151+
- This is application-layer use of authoritative state, not compiler semantics.
152+
125153
## Troubleshooting
126154

127155
- `litellm is required`: install `context-compiler[integrations]` (or `context-compiler[experimental]` for preprocessor).
@@ -145,6 +173,11 @@ Decision flow in both examples:
145173
- `clarify`: show `prompt_to_user`; do not treat state as changed.
146174
- `update`: state changed; use updated state for the next model call.
147175

176+
Decision flow in `response_format.py`:
177+
- `passthrough`: let the host decide whether to send `response_format`.
178+
- `clarify`: show `prompt_to_user`; do not call LiteLLM.
179+
- `update`: state changed; the next host request may use a different `response_format`.
180+
148181
## Example checks
149182

150183
- Near-miss passthrough (`with_preprocessor.py`):
@@ -158,3 +191,16 @@ Decision flow in both examples:
158191
- `use podman instead of docker` without prior `use docker` -> replacement clarify.
159192
- Directive-adjacent abstain (`with_preprocessor.py`):
160193
- `change premise concise replies` is classified as `unknown`, not rewritten, and handled by engine clarify.
194+
- Host-side request shaping (`response_format.py`):
195+
- `use compact_summary` -> host selects compact-summary `response_format`.
196+
- `use action_plan` -> host selects action-plan `response_format`.
197+
- `prohibit compact_summary` -> host omits that `response_format`.
198+
199+
## Optional smoke run for `response_format.py`
200+
201+
```shell
202+
export RUN_LITELLM_SMOKE=1
203+
export OPENAI_API_KEY=...
204+
export MODEL=openai/gpt-4o-mini
205+
uv run python examples/integrations/litellm/response_format.py
206+
```
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Minimal LiteLLM response_format selection from authoritative state.
2+
3+
Flow:
4+
Context Compiler state -> host response_format decision -> LiteLLM model call.
5+
6+
This example keeps model execution optional so tests can validate behavior
7+
without a live provider.
8+
"""
9+
10+
import os
11+
from collections.abc import Callable, Mapping
12+
from importlib import import_module
13+
from typing import Any, TypedDict, cast
14+
15+
from context_compiler import (
16+
POLICY_PROHIBIT,
17+
POLICY_USE,
18+
State,
19+
create_engine,
20+
get_clarify_prompt,
21+
get_decision_state,
22+
get_policy_items,
23+
is_clarify,
24+
)
25+
from context_compiler.engine import Engine
26+
27+
try:
28+
from host_support import print_startup_config, resolve_provider_config
29+
except ImportError:
30+
from host_support.provider_mode import print_startup_config, resolve_provider_config
31+
32+
COMPACT_SUMMARY_RESPONSE_FORMAT: dict[str, Any] = {
33+
"type": "json_schema",
34+
"json_schema": {
35+
"name": "compact_summary",
36+
"schema": {
37+
"type": "object",
38+
"properties": {
39+
"summary": {
40+
"type": "string",
41+
"description": "A compact summary of the answer.",
42+
}
43+
},
44+
"required": ["summary"],
45+
"additionalProperties": False,
46+
},
47+
},
48+
}
49+
50+
ACTION_PLAN_RESPONSE_FORMAT: dict[str, Any] = {
51+
"type": "json_schema",
52+
"json_schema": {
53+
"name": "action_plan",
54+
"schema": {
55+
"type": "object",
56+
"properties": {
57+
"steps": {
58+
"type": "array",
59+
"items": {"type": "string"},
60+
"description": "Ordered next steps for the user.",
61+
}
62+
},
63+
"required": ["steps"],
64+
"additionalProperties": False,
65+
},
66+
},
67+
}
68+
69+
_RESPONSE_FORMAT_BY_ITEM: dict[str, dict[str, Any]] = {
70+
"compact_summary": COMPACT_SUMMARY_RESPONSE_FORMAT,
71+
"action_plan": ACTION_PLAN_RESPONSE_FORMAT,
72+
}
73+
74+
75+
class TurnPlan(TypedDict):
76+
decision_kind: str
77+
clarify_prompt: str | None
78+
selected_response_format_item: str | None
79+
response_format: dict[str, Any] | None
80+
81+
82+
class _LiteLLMCallKwargs(TypedDict, total=False):
83+
model: str
84+
messages: list[dict[str, str]]
85+
temperature: float
86+
api_base: str
87+
api_key: str
88+
response_format: dict[str, Any]
89+
90+
91+
def select_litellm_response_format(state: State) -> tuple[str | None, dict[str, Any] | None]:
92+
"""Return (policy_item, response_format) or (None, None) when no safe match exists."""
93+
94+
use_items = set(get_policy_items(state, POLICY_USE))
95+
prohibit_items = set(get_policy_items(state, POLICY_PROHIBIT))
96+
97+
for item, response_format in _RESPONSE_FORMAT_BY_ITEM.items():
98+
if item in use_items and item not in prohibit_items:
99+
return item, response_format
100+
101+
return None, None
102+
103+
104+
def plan_turn(user_input: str, engine: Engine) -> TurnPlan:
105+
"""Run compiler step and decide whether to request LiteLLM structured output."""
106+
107+
decision = engine.step(user_input)
108+
if is_clarify(decision):
109+
return {
110+
"decision_kind": "clarify",
111+
"clarify_prompt": get_clarify_prompt(decision),
112+
"selected_response_format_item": None,
113+
"response_format": None,
114+
}
115+
116+
decision_state = get_decision_state(decision)
117+
compiled_state = decision_state if decision_state is not None else engine.state
118+
selected_item, response_format = select_litellm_response_format(compiled_state)
119+
120+
return {
121+
"decision_kind": str(decision["kind"]),
122+
"clarify_prompt": None,
123+
"selected_response_format_item": selected_item,
124+
"response_format": response_format,
125+
}
126+
127+
128+
def _get_litellm_completion() -> Callable[..., object]:
129+
litellm_module = import_module("litellm")
130+
return cast(Callable[..., object], litellm_module.completion)
131+
132+
133+
def _extract_response_content(response: object) -> str | None:
134+
if isinstance(response, Mapping):
135+
choices = response.get("choices")
136+
if isinstance(choices, list) and choices:
137+
first = choices[0]
138+
if isinstance(first, Mapping):
139+
message = first.get("message")
140+
if isinstance(message, Mapping):
141+
content = message.get("content")
142+
if isinstance(content, str):
143+
return content
144+
145+
choices_attr = getattr(response, "choices", None)
146+
if isinstance(choices_attr, list) and choices_attr:
147+
first = choices_attr[0]
148+
message_attr = getattr(first, "message", None)
149+
content_attr = getattr(message_attr, "content", None)
150+
if isinstance(content_attr, str):
151+
return content_attr
152+
153+
return None
154+
155+
156+
def optional_litellm_call(
157+
*,
158+
user_input: str,
159+
response_format: Mapping[str, Any] | None,
160+
) -> str:
161+
"""Optional smoke call to LiteLLM.
162+
163+
If `response_format` is provided, it is passed through unchanged.
164+
"""
165+
166+
try:
167+
completion = _get_litellm_completion()
168+
except ModuleNotFoundError as exc:
169+
raise RuntimeError("litellm is required. Install with: pip install litellm") from exc
170+
171+
config = resolve_provider_config(default_model="openai/gpt-4o-mini")
172+
print_startup_config(config)
173+
174+
kwargs: _LiteLLMCallKwargs = {
175+
"model": config.model,
176+
"messages": [{"role": "user", "content": user_input}],
177+
"temperature": 0,
178+
"api_base": config.base_url,
179+
}
180+
if config.api_key:
181+
kwargs["api_key"] = config.api_key
182+
if response_format is not None:
183+
kwargs["response_format"] = dict(response_format)
184+
185+
response = completion(**kwargs)
186+
content = _extract_response_content(response)
187+
if content is None:
188+
raise RuntimeError("LiteLLM response missing choices[0].message.content")
189+
return content
190+
191+
192+
def main() -> None:
193+
engine = create_engine()
194+
195+
# Demonstration setup.
196+
engine.step("use compact_summary")
197+
engine.step("prohibit action_plan")
198+
199+
plan = plan_turn("Summarize what changed in this project.", engine)
200+
print("decision_kind:", plan["decision_kind"])
201+
print("selected_response_format_item:", plan["selected_response_format_item"])
202+
print("response_format_selected:", plan["response_format"] is not None)
203+
204+
# Optional model execution path; disabled by default.
205+
if os.getenv("RUN_LITELLM_SMOKE") == "1":
206+
response = optional_litellm_call(
207+
user_input="Summarize what changed in this project.",
208+
response_format=plan["response_format"],
209+
)
210+
print("litellm_response:", response)
211+
212+
213+
if __name__ == "__main__":
214+
main()

tests/test_example_integrations_imports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def _guarded_import(
8383
("module_path", "blocked_prefixes", "needs_openwebui_stubs"),
8484
[
8585
(INTEGRATIONS_DIR / "litellm" / "basic.py", ("litellm",), False),
86+
(INTEGRATIONS_DIR / "litellm" / "response_format.py", ("litellm",), False),
8687
(INTEGRATIONS_DIR / "litellm" / "with_preprocessor.py", ("litellm",), False),
8788
(
8889
INTEGRATIONS_DIR / "ollama_structured_output" / "example.py",

0 commit comments

Comments
 (0)