Skip to content

Commit 05ca033

Browse files
authored
feat: ChatInterfaze — in-repo LangChain chat model (#5) (#12)
* feat: ChatInterfaze LangChain chat model (#5) Add an in-repo langchain-openai chat model (optional interfaze[langchain] extra) that fixes the ChatOpenAI(base_url=...) fallback gaps: - surfaces Interfaze's custom response fields (precontext/reasoning/vcache) on response_metadata + additional_kwargs (non-stream and stream paths) - injects request-side precontext via extra_body - accepts {"type":"video",...} content blocks by rewriting them to a file part before the stock converter rejects them - strips inline <think>/<precontext> tags from content Not imported from the package root, so the core SDK stays langchain-free. Ships as an optional extra with mocked unit tests. * langchain: fix cross-chunk side-channel stripping + forward video MIME (review #12) - _stream/_astream now filter <think>/<precontext> statefully across chunk boundaries (_SideChannelFilter) and recover reasoning/precontext from the full accumulation at stream-end, instead of the per-chunk strip that dropped split tags. - _convert_video_block forwards mime_type as file.format (else server sees octet-stream). * langchain: mypy override so --strict passes without the extra (disallow_subclassing_any + warn_return_any) (review #12)
1 parent cf14ffa commit 05ca033

5 files changed

Lines changed: 1550 additions & 2 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,24 @@ URLs and base64 work; raw `bytes` do **not** (must be base64-encoded — this SD
121121
- Control options: `Interfaze(show_additional_info=..., bypass_moe=..., bypass_cache=..., admin_key=...)`.
122122
- Custom params: pass `extra_body={...}` / `extra_headers={...}` straight through to the request.
123123

124+
## LangChain
125+
126+
`pip install interfaze[langchain]` adds a chat model that points at Interfaze and surfaces the
127+
extras the stock `ChatOpenAI` drops:
128+
129+
```python
130+
from interfaze.langchain import ChatInterfaze
131+
132+
llm = ChatInterfaze() # reads INTERFAZE_API_KEY
133+
res = llm.invoke("Summarize the latest AI news")
134+
print(res.content)
135+
print(res.response_metadata.get("precontext"), res.response_metadata.get("vcache"))
136+
```
137+
138+
`precontext`/`reasoning`/`vcache` land on `response_metadata`; `{"type": "video", ...}` content
139+
blocks are accepted; inline `<think>`/`<precontext>` tags are stripped. Send request-side
140+
precontext with `ChatInterfaze(precontext=[...])`.
141+
124142
## Good to know
125143

126144
- Interfaze implements `chat.completions` and `models`; other OpenAI endpoints are not exposed.

pyproject.toml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,15 @@ Homepage = "https://interfaze.ai"
2323
Repository = "https://github.com/interfaze/interfaze-py"
2424

2525
[project.optional-dependencies]
26-
dev = ["pytest>=8", "respx>=0.21", "mypy>=1.11", "ruff>=0.6", "pydantic>=2"]
26+
langchain = ["langchain-openai>=1.3.5; python_version>='3.10'"]
27+
dev = [
28+
"pytest>=8",
29+
"respx>=0.21",
30+
"mypy>=1.11",
31+
"ruff>=0.6",
32+
"pydantic>=2",
33+
"langchain-openai>=1.3.5; python_version>='3.10'",
34+
]
2735

2836
[tool.hatch.build.targets.wheel]
2937
packages = ["src/interfaze"]
@@ -38,3 +46,12 @@ line-length = 110
3846
python_version = "3.10"
3947
strict = true
4048
files = ["src/interfaze"]
49+
50+
[[tool.mypy.overrides]]
51+
module = ["langchain_openai.*", "langchain_core.*"]
52+
ignore_missing_imports = true
53+
54+
[[tool.mypy.overrides]]
55+
module = ["interfaze.langchain"]
56+
disallow_subclassing_any = false
57+
warn_return_any = false

src/interfaze/langchain.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""LangChain chat model for Interfaze.
2+
3+
Not imported from ``interfaze/__init__.py`` — the core SDK must not require
4+
``langchain-openai``. Import this module directly: ``from interfaze.langchain import ChatInterfaze``.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
11+
12+
from ._constants import INTERFAZE_BASE_URL, INTERFAZE_MODEL
13+
from ._errors import InterfazeError
14+
from ._stream import _SideChannelFilter, strip_side_channels
15+
16+
try:
17+
from langchain_core.language_models import LanguageModelInput
18+
from langchain_core.messages import AIMessage, AIMessageChunk
19+
from langchain_core.outputs import ChatGenerationChunk, ChatResult
20+
from langchain_openai import ChatOpenAI
21+
from pydantic import Field, SecretStr
22+
except ImportError as exc:
23+
raise InterfazeError(
24+
"ChatInterfaze requires the `langchain` extra. Install it with `pip install interfaze[langchain]`."
25+
) from exc
26+
27+
_SIDE_FIELDS = ("precontext", "reasoning", "vcache")
28+
29+
30+
def _extract_side_fields(data: Dict[str, Any]) -> Dict[str, Any]:
31+
"""Pull Interfaze's non-standard top-level response fields out of a raw chunk/response dict."""
32+
return {k: data[k] for k in _SIDE_FIELDS if data.get(k) is not None}
33+
34+
35+
def _apply_side_fields(message: AIMessage, side: Dict[str, Any]) -> None:
36+
for key, value in side.items():
37+
message.response_metadata[key] = value
38+
message.additional_kwargs[key] = value
39+
40+
41+
def _strip_tags(message: AIMessage) -> None:
42+
"""Strip inline ``<think>``/``<precontext>`` tags from message content when present."""
43+
if not isinstance(message.content, str) or not (
44+
"<think>" in message.content or "<precontext>" in message.content
45+
):
46+
return
47+
text, reasoning, precontext = strip_side_channels(message.content)
48+
if text != message.content:
49+
message.content = text
50+
if reasoning:
51+
message.response_metadata.setdefault("reasoning", reasoning)
52+
message.additional_kwargs.setdefault("reasoning", reasoning)
53+
if precontext:
54+
message.response_metadata.setdefault("precontext", precontext)
55+
message.additional_kwargs.setdefault("precontext", precontext)
56+
57+
58+
def _convert_video_block(block: Dict[str, Any]) -> Dict[str, Any]:
59+
"""Interfaze has no native video content type; it accepts video as a file part."""
60+
mime = block.get("mime_type")
61+
if "url" in block:
62+
file: Dict[str, Any] = {"file_data": block["url"]}
63+
elif "base64" in block:
64+
mime = mime or "video/mp4"
65+
file = {"file_data": f"data:{mime};base64,{block['base64']}"}
66+
elif "file_id" in block:
67+
file = {"file_id": block["file_id"]}
68+
else:
69+
raise InterfazeError("Video content block requires one of 'url', 'base64', or 'file_id'.")
70+
if mime:
71+
file["format"] = mime
72+
extras = block.get("extras")
73+
if isinstance(extras, dict) and extras.get("filename"):
74+
file["filename"] = extras["filename"]
75+
return {"type": "file", "file": file}
76+
77+
78+
def _rewrite_video_blocks(content: Any) -> Any:
79+
if not isinstance(content, list):
80+
return content
81+
rewritten = [
82+
_convert_video_block(block) if isinstance(block, dict) and block.get("type") == "video" else block
83+
for block in content
84+
]
85+
return rewritten if rewritten != content else content
86+
87+
88+
def _filter_stream_chunk(gen: ChatGenerationChunk, filt: _SideChannelFilter, raw: List[str]) -> None:
89+
"""Rewrite a streamed chunk's content to visible-only text, accumulating the raw content."""
90+
message = gen.message
91+
if isinstance(message, AIMessage) and isinstance(message.content, str) and message.content:
92+
raw.append(message.content)
93+
message.content = filt.feed(message.content)
94+
95+
96+
def _final_side_chunk(filt: _SideChannelFilter, raw: List[str]) -> Optional[ChatGenerationChunk]:
97+
"""After a stream ends, emit any buffered tail plus reasoning/precontext recovered from the whole."""
98+
tail = filt.flush()
99+
_, reasoning, precontext = strip_side_channels("".join(raw))
100+
if not tail and not reasoning and not precontext:
101+
return None
102+
message = AIMessageChunk(content=tail)
103+
side: Dict[str, Any] = {}
104+
if reasoning:
105+
side["reasoning"] = reasoning
106+
if precontext:
107+
side["precontext"] = precontext
108+
_apply_side_fields(message, side)
109+
return ChatGenerationChunk(message=message)
110+
111+
112+
class ChatInterfaze(ChatOpenAI):
113+
"""`ChatOpenAI` pointed at Interfaze, with precontext/reasoning/vcache and video support."""
114+
115+
precontext: Optional[List[Dict[str, Any]]] = Field(default=None)
116+
"""Pre-computed tool output to feed Interfaze (skips its internal tool run); sent via ``extra_body``."""
117+
118+
def __init__(
119+
self,
120+
*,
121+
api_key: Optional[str] = None,
122+
base_url: Optional[str] = None,
123+
model: Optional[str] = None,
124+
**kwargs: Any,
125+
) -> None:
126+
key = api_key or os.environ.get("INTERFAZE_API_KEY")
127+
if not key:
128+
raise InterfazeError(
129+
"Missing API key. Pass ChatInterfaze(api_key=...) or set the INTERFAZE_API_KEY "
130+
"environment variable."
131+
)
132+
super().__init__(
133+
api_key=SecretStr(key),
134+
base_url=base_url or INTERFAZE_BASE_URL,
135+
model=model or INTERFAZE_MODEL,
136+
**kwargs,
137+
)
138+
139+
def _get_request_payload(
140+
self,
141+
input_: LanguageModelInput,
142+
*,
143+
stop: Optional[List[str]] = None,
144+
**kwargs: Any,
145+
) -> Dict[str, Any]:
146+
messages = self._convert_input(input_).to_messages()
147+
patched = [
148+
m.model_copy(update={"content": _rewrite_video_blocks(m.content)})
149+
if isinstance(m.content, list)
150+
else m
151+
for m in messages
152+
]
153+
payload = super()._get_request_payload(patched, stop=stop, **kwargs)
154+
if self.precontext is not None:
155+
extra_body = dict(payload.get("extra_body") or {})
156+
extra_body.setdefault("precontext", self.precontext)
157+
payload["extra_body"] = extra_body
158+
return payload
159+
160+
def _create_chat_result(
161+
self,
162+
response: Any,
163+
generation_info: Optional[Dict[str, Any]] = None,
164+
) -> ChatResult:
165+
result = super()._create_chat_result(response, generation_info)
166+
response_dict = (
167+
response
168+
if isinstance(response, dict)
169+
else response.model_dump(
170+
exclude={"choices": {"__all__": {"message": {"parsed"}}}}, warnings=False
171+
)
172+
)
173+
side = _extract_side_fields(response_dict)
174+
for generation in result.generations:
175+
message = generation.message
176+
if isinstance(message, AIMessage):
177+
_apply_side_fields(message, side)
178+
_strip_tags(message)
179+
return result
180+
181+
def _convert_chunk_to_generation_chunk(
182+
self,
183+
chunk: Dict[str, Any],
184+
default_chunk_class: type,
185+
base_generation_info: Optional[Dict[str, Any]],
186+
) -> Optional[ChatGenerationChunk]:
187+
generation_chunk = super()._convert_chunk_to_generation_chunk(
188+
chunk, default_chunk_class, base_generation_info
189+
)
190+
if generation_chunk is None:
191+
return generation_chunk
192+
message = generation_chunk.message
193+
if isinstance(message, AIMessage):
194+
side = _extract_side_fields(chunk)
195+
if side:
196+
_apply_side_fields(message, side)
197+
return generation_chunk
198+
199+
def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGenerationChunk]:
200+
filt = _SideChannelFilter()
201+
raw: List[str] = []
202+
for gen in super()._stream(*args, **kwargs):
203+
_filter_stream_chunk(gen, filt, raw)
204+
yield gen
205+
final = _final_side_chunk(filt, raw)
206+
if final is not None:
207+
yield final
208+
209+
async def _astream(self, *args: Any, **kwargs: Any) -> AsyncIterator[ChatGenerationChunk]:
210+
filt = _SideChannelFilter()
211+
raw: List[str] = []
212+
async for gen in super()._astream(*args, **kwargs):
213+
_filter_stream_chunk(gen, filt, raw)
214+
yield gen
215+
final = _final_side_chunk(filt, raw)
216+
if final is not None:
217+
yield final
218+
219+
220+
__all__ = ["ChatInterfaze"]

0 commit comments

Comments
 (0)