|
| 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