From 82b0be58860ae63528ee24d913866e656768c469 Mon Sep 17 00:00:00 2001 From: Rens Date: Fri, 29 May 2026 18:02:39 +0200 Subject: [PATCH 1/6] tmp fix for tests on main --- ipyai/api_client.py | 10 ++++++++-- ipyai/kernel_bridge.py | 4 +++- ipyai/lisette_compat.py | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 ipyai/lisette_compat.py diff --git a/ipyai/api_client.py b/ipyai/api_client.py index 873bf52..95c5289 100644 --- a/ipyai/api_client.py +++ b/ipyai/api_client.py @@ -5,6 +5,7 @@ contents, mk_tr_details) from .backend_common import BaseBackend, ConversationSeed, compact_tool, seed_to_flat_history +from .lisette_compat import full_response_sentinel_text, is_full_response, strip_full_response_sentinel class _BridgeNS(dict): @@ -40,6 +41,11 @@ def _emit(self, text): if text: self.display_text += text return text or "" + def _tool_result_for_outp(self, o): + content = o.get("content") + if is_full_response(content): return {**o, "content": full_response_sentinel_text(content)} + return o + def format_item(self, o): if isinstance(o, ModelResponse): if tcs := getattr(contents(o), "tool_calls", None): @@ -48,10 +54,10 @@ def format_item(self, o): if isinstance(o, dict) and "tool_call_id" in o: tc = self.tcs.pop(o["tool_call_id"], None) if tc is not None: - self.outp += mk_tr_details(o, tc, mx=self.mx) + self.outp += mk_tr_details(self._tool_result_for_outp(o), tc, mx=self.mx) self.final_text = self.outp args = json.loads(tc.function.arguments or "{}") - return self._emit(compact_tool(tc.function.name, args, o.get("content") or "")) + return self._emit(compact_tool(tc.function.name, args, strip_full_response_sentinel(o.get("content") or ""))) res = super().format_item(o) self.final_text = self.outp return self._emit(res) diff --git a/ipyai/kernel_bridge.py b/ipyai/kernel_bridge.py index fead9f3..1f21006 100644 --- a/ipyai/kernel_bridge.py +++ b/ipyai/kernel_bridge.py @@ -2,6 +2,8 @@ import ast, asyncio, json from queue import Empty +from .lisette_compat import full_response_sentinel_text + CUSTOM_TOOL_NAMES = ("pyrun", "bash", "start_bgterm", "write_stdin", "close_bgterm", "lnhashview_file", "exhash_file", "list_pyskills") _INJECT_IMPORTS = dict(bash="from safecmd import bash", start_bgterm="from bgterm import start_bgterm", @@ -120,7 +122,7 @@ async def call_tool(self, name, args=None): text = res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, default=str) if exprs.get("_full"): from lisette.core import FullResponse - return FullResponse(text) + return FullResponse(full_response_sentinel_text(text)) return text async def read_var(self, name): diff --git a/ipyai/lisette_compat.py b/ipyai/lisette_compat.py new file mode 100644 index 0000000..a78bc4e --- /dev/null +++ b/ipyai/lisette_compat.py @@ -0,0 +1,25 @@ +"Compatibility helpers for lisette response formatting contracts." + +FULL_RESPONSE_SENTINEL = "š" + + +def is_full_response(value): + "Return whether `value` is a lisette-style FullResponse without importing lisette." + return any(cls.__name__ == "FullResponse" for cls in type(value).__mro__) + + +def full_response_sentinel_text(value): + "Wrap `value` in lisette's serialization-safe no-truncation sentinel." + text = str(value) + if len(text) > 2 and text[0] == FULL_RESPONSE_SENTINEL and text[-1] == FULL_RESPONSE_SENTINEL: + return text + return f"{FULL_RESPONSE_SENTINEL}{text}{FULL_RESPONSE_SENTINEL}" + + +def strip_full_response_sentinel(value): + "Strip lisette's no-truncation sentinel from display text." + if not isinstance(value, str): return value + text = str(value) + if len(text) > 2 and text[0] == FULL_RESPONSE_SENTINEL and text[-1] == FULL_RESPONSE_SENTINEL: + return text[1:-1] + return value From ff2a1dac8a40ab1a155d3a9a2138532a17622110 Mon Sep 17 00:00:00 2001 From: Rens Date: Fri, 29 May 2026 14:04:59 +0200 Subject: [PATCH 2/6] minimal tools --- ipyai/app.py | 3 +-- ipyai/core.py | 8 +++++--- ipyai/kernel_bridge.py | 20 +++++--------------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/ipyai/app.py b/ipyai/app.py index 3c76374..26a5ef5 100644 --- a/ipyai/app.py +++ b/ipyai/app.py @@ -123,8 +123,7 @@ def _kernel_startup_code(self): def _bootstrap_kernel(self): async def _go(): await self._bridge._exec(self._kernel_startup_code()) - present = set(await self._bridge.present_names(CUSTOM_TOOL_NAMES)) - await self._bridge.inject_tools(skip=present) + await self._bridge.inject_tools() await self._bridge.available_names(force=True) return await self._bridge.history_db_info() return asyncio.get_event_loop().run_until_complete(_go()) diff --git a/ipyai/core.py b/ipyai/core.py index ade02ad..89d8464 100644 --- a/ipyai/core.py +++ b/ipyai/core.py @@ -48,7 +48,9 @@ def _tde_on_text(self, context, text): - use available shell/file tools for repository work - use web tools when fresh web context matters -Respond concisely and practically. Markdown is rendered in a terminal with Rich.""" +Respond concisely and practically. Markdown is rendered in a terminal with Rich. + +Run `pyrun` and `bash` as code execution tools. In pyrun use `list_pyskills()` to list pyskills which are available AI tool modules. Import them as needed. Run `doc(sym)` inside pyrun to render docs for a module, class, function, instance, or any other Python object.""" _COMPLETION_SP = "You are a code completion engine for IPython. Return only the completion text to insert at the cursor." MAGIC_NAME = "ipyai" @@ -367,8 +369,8 @@ def load_config(path=None, backend_name=None): def load_sysp(path=None): path = Path(path or SYSP_PATH) _ensure_config_dir(path) - if not path.exists(): path.write_text(DEFAULT_SYSTEM_PROMPT) - return path.read_text() + custom = path.read_text() if path.exists() else "" + return DEFAULT_SYSTEM_PROMPT + ("\n\n" + custom if custom else "") def _cell_id(): return uuid.uuid4().hex[:8] diff --git a/ipyai/kernel_bridge.py b/ipyai/kernel_bridge.py index 1f21006..42792de 100644 --- a/ipyai/kernel_bridge.py +++ b/ipyai/kernel_bridge.py @@ -5,11 +5,8 @@ from .lisette_compat import full_response_sentinel_text -CUSTOM_TOOL_NAMES = ("pyrun", "bash", "start_bgterm", "write_stdin", "close_bgterm", "lnhashview_file", "exhash_file", "list_pyskills") -_INJECT_IMPORTS = dict(bash="from safecmd import bash", start_bgterm="from bgterm import start_bgterm", - write_stdin="from bgterm import write_stdin", close_bgterm="from bgterm import close_bgterm", - lnhashview_file="from exhash import lnhashview_file", exhash_file="from exhash import exhash_file", - list_pyskills="from pyskills import list_pyskills") +CUSTOM_TOOL_NAMES = ("pyrun", "bash") +_INJECT_IMPORTS = ("from pyskills import list_pyskills, doc", "from safecmd import bash", "from safepyrun import *") _EXEC_TIMEOUT = 20 _TOOL_TIMEOUT = 600 @@ -73,16 +70,9 @@ async def _exec(self, code, *, expressions=None, capture_stream=False, timeout=_ exprs = {k: _expr_value(v) for k,v in (content.get("user_expressions") or {}).items()} return exprs, "".join(stream) if stream is not None else "" - async def present_names(self, names): - "Return subset of `names` already defined and callable in the kernel's user_ns." - probe = "[n for n in %r if n in globals() and callable(globals()[n])]" % list(names) - exprs,_ = await self._exec("", expressions={"_r": probe}) - return list(exprs.get("_r") or []) - - async def inject_tools(self, skip=()): - "Import the custom tool names (other than pyrun, which must come from an extension)." - skip = set(skip) - stmts = [_INJECT_IMPORTS[n] for n in CUSTOM_TOOL_NAMES if n in _INJECT_IMPORTS and n not in skip] + async def inject_tools(self): + "Run the default namespace imports; pyrun comes from an extension." + stmts = list(_INJECT_IMPORTS) for stmt in stmts: try: await self._exec(stmt) except Exception: pass From 6ec17666969fcd3d5c9ec2e83247b1303e1cfdb2 Mon Sep 17 00:00:00 2001 From: Rens Date: Fri, 29 May 2026 18:13:20 +0200 Subject: [PATCH 3/6] fix tests --- tests/conftest.py | 5 ++--- tests/test_kernel_existing.py | 7 +++---- tests/test_kernel_lifecycle.py | 25 ++++++++++--------------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 73dbeb0..e223179 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import pytest import ipyai.core as core -from ipyai.kernel_bridge import CUSTOM_TOOL_NAMES, KernelBridge +from ipyai.kernel_bridge import KernelBridge _IPYTHONDIR_SESSION = None @@ -125,8 +125,7 @@ def test_db(): async def _prepare_kernel_bridge(client): bridge = KernelBridge(client) await bridge._exec(_KERNEL_BOOTSTRAP) - present = set(await bridge.present_names(CUSTOM_TOOL_NAMES)) - await bridge.inject_tools(skip=present) + await bridge.inject_tools() await bridge.available_names(force=True) return bridge diff --git a/tests/test_kernel_existing.py b/tests/test_kernel_existing.py index 92befa9..269c14e 100644 --- a/tests/test_kernel_existing.py +++ b/tests/test_kernel_existing.py @@ -4,7 +4,7 @@ from jupyter_client.asynchronous.client import AsyncKernelClient from jupyter_client.manager import KernelManager -from ipyai.kernel_bridge import CUSTOM_TOOL_NAMES, KernelBridge +from ipyai.kernel_bridge import KernelBridge from ipyai.shell import IPyAIHistory @@ -41,8 +41,8 @@ async def _go(): await secondary.wait_for_ready(timeout=30) sb = KernelBridge(secondary) - present = set(await sb.present_names(CUSTOM_TOOL_NAMES)) - assert "pyrun" in present, "secondary client should see pyrun already present (from primary's bootstrap)" + names = set(await sb.available_names(force=True)) + assert "pyrun" in names, "secondary client should see pyrun already present (from primary's bootstrap)" val = await sb.read_var("hidden") assert val == "walnut" @@ -77,4 +77,3 @@ async def _go(): km.shutdown_kernel(now=False) try: loop.close() except Exception: pass - diff --git a/tests/test_kernel_lifecycle.py b/tests/test_kernel_lifecycle.py index 4bdff4d..5d4b2e5 100644 --- a/tests/test_kernel_lifecycle.py +++ b/tests/test_kernel_lifecycle.py @@ -1,10 +1,10 @@ -"Separate kernel process: spawn + bootstrap + skip-existing-on-inject + shutdown." +"Separate kernel process: spawn + bootstrap + authoritative tool injection + shutdown." import asyncio from jupyter_client.asynchronous.client import AsyncKernelClient from jupyter_client.manager import KernelManager -from ipyai.kernel_bridge import CUSTOM_TOOL_NAMES, KernelBridge +from ipyai.kernel_bridge import KernelBridge _BOOTSTRAP = ("from IPython import get_ipython\n" @@ -14,7 +14,7 @@ "_ip.history_manager.db_log_output = True\n") -def test_spawn_bootstrap_skip_inject_shutdown(): +def test_spawn_bootstrap_overwrites_existing_tools_shutdown(): km = KernelManager() km.start_kernel(extra_arguments=["--HistoryManager.enabled=True"]) loop = asyncio.new_event_loop() @@ -28,22 +28,17 @@ async def _go(): bridge = KernelBridge(client) await bridge._exec(_BOOTSTRAP) - present_after_bootstrap = set(await bridge.present_names(CUSTOM_TOOL_NAMES)) - assert "pyrun" in present_after_bootstrap, "safepyrun extension should seed pyrun" + await bridge.inject_tools() + names = set(await bridge.available_names(force=True)) + assert {"pyrun", "bash"} <= names await bridge._exec("def bash(**kw): return 'sentinel-preseeded'") - present_with_preseed = set(await bridge.present_names(CUSTOM_TOOL_NAMES)) - assert "bash" in present_with_preseed, "preseeded callable should count as present" - - await bridge.inject_tools(skip=present_with_preseed) - res = await bridge.call_tool("bash", {}) - assert "sentinel-preseeded" in res, f"inject_tools with skip should have preserved preseeded bash; got {res!r}" + assert "sentinel-preseeded" in res, f"preseeded bash should be active before reinjection; got {res!r}" - await bridge._exec("globals().pop('bash', None)") - await bridge.inject_tools(skip=set(await bridge.present_names(CUSTOM_TOOL_NAMES))) - names = set(await bridge.available_names(force=True)) - assert "bash" in names, "after removing preseed and re-injecting, real bash should land" + await bridge.inject_tools() + res = await bridge.call_tool("bash", dict(cmd="printf 'real\\n'", as_dict=True)) + assert "real" in res and "sentinel-preseeded" not in res, f"inject_tools should overwrite preseeded bash; got {res!r}" try: await client.stop_channels() except Exception: client.stop_channels() From 34b4088c7160258f7151323dc93c46f26d1de8a1 Mon Sep 17 00:00:00 2001 From: Rens Date: Fri, 29 May 2026 16:56:11 +0200 Subject: [PATCH 4/6] mv to fastllm --- ipyai/api_client.py | 22 +++++++++++----------- ipyai/backend_common.py | 2 +- ipyai/kernel_bridge.py | 1 + 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ipyai/api_client.py b/ipyai/api_client.py index 95c5289..912eca2 100644 --- a/ipyai/api_client.py +++ b/ipyai/api_client.py @@ -1,7 +1,7 @@ import json, warnings from litellm.types.utils import ModelResponse -from lisette.core import (AsyncChat as LisetteAsyncChat, AsyncStreamFormatter as LisetteAsyncStreamFormatter, +from fastllm.chat import (AsyncChat as FastLLMAsyncChat, AsyncStreamFormatter as FastLLMAsyncStreamFormatter, contents, mk_tr_details) from .backend_common import BaseBackend, ConversationSeed, compact_tool, seed_to_flat_history @@ -9,7 +9,7 @@ class _BridgeNS(dict): - "Dict-shaped proxy so lisette's ns-based tool-call path routes through the ToolRegistry bridge. Pass-through — any `FullResponse` a tool returns survives, and plain-str results go through lisette's default truncation." + "Dict-shaped proxy so chat's ns-based tool-call path routes through the ToolRegistry bridge. Pass-through — any `FullResponse` a tool returns survives, and plain-str results go through chat's default truncation." def __init__(self, registry): super().__init__() self._reg = registry @@ -30,8 +30,8 @@ def __getitem__(self, name): return self.get(name) warnings.filterwarnings("ignore", message="Pydantic serializer warnings", category=UserWarning) -class AsyncStreamFormatter(LisetteAsyncStreamFormatter): - "Streams via lisette's formatter so `outp` keeps full lisette `
` tool blocks (round-trippable through `fmt2hist`), but emits a compact `šŸ”§ ...` line per tool result for the live terminal display." +class AsyncStreamFormatter(FastLLMAsyncStreamFormatter): + "Streams via fastllm's formatter so `outp` keeps full fastllm `
` tool blocks (round-trippable through `fmt2hist`), but emits a compact `šŸ”§ ...` line per tool result for the live terminal display." def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.final_text = "" @@ -63,7 +63,7 @@ def format_item(self, o): return self._emit(res) -class _LisetteBackend(BaseBackend): +class _FastLLMBackend(BaseBackend): formatter_cls = AsyncStreamFormatter def _make_chat(self, **kw): raise NotImplementedError @@ -77,12 +77,12 @@ async def prepare_turn(self, *, prompt, model, think="l", provider_session_id=No return self.prepared_turn(stream, provider_session_id=provider_session_id) -class ClaudeAPIBackend(_LisetteBackend): - def _make_chat(self, **kw): return LisetteAsyncChat(**kw, cache=True) +class ClaudeAPIBackend(_FastLLMBackend): + def _make_chat(self, **kw): return FastLLMAsyncChat(**kw, cache=True) -class CodexAPIBackend(_LisetteBackend): - "Codex API backend via lisette `AsyncChat` + chatgpt provider aliases (codex54/codex55 resolve to `chatgpt/gpt-5.x`). Short config names like 'gpt-5.4' are auto-prefixed with `chatgpt/` for backward compat." +class CodexAPIBackend(_FastLLMBackend): + "Codex API backend via fastllm's codex vendor, using `~/.codex/auth.json`. Accepts short names like `gpt-5.4` or prefixed names like `codex/gpt-5.4`." def _make_chat(self, model, **kw): - if "/" not in model: model = f"chatgpt/{model}" - return LisetteAsyncChat(model=model, **kw) + if "/" in model: _,model = model.split("/", 1) + return FastLLMAsyncChat(model=model, vendor_name="codex", **kw) diff --git a/ipyai/backend_common.py b/ipyai/backend_common.py index c41a154..dcc0c35 100644 --- a/ipyai/backend_common.py +++ b/ipyai/backend_common.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import AsyncIterator, Literal -from lisette.core import trunc_param +from fastllm.chat import _trunc_param as trunc_param from .tooling import ToolRegistry diff --git a/ipyai/kernel_bridge.py b/ipyai/kernel_bridge.py index 42792de..26fd7ad 100644 --- a/ipyai/kernel_bridge.py +++ b/ipyai/kernel_bridge.py @@ -112,6 +112,7 @@ async def call_tool(self, name, args=None): text = res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, default=str) if exprs.get("_full"): from lisette.core import FullResponse + from fastllm.chat import FullResponse return FullResponse(full_response_sentinel_text(text)) return text From fab0f67f8cd06f1507b76911d9e4a00be7fc7694 Mon Sep 17 00:00:00 2001 From: Rens Date: Fri, 29 May 2026 18:46:08 +0200 Subject: [PATCH 5/6] fix tests and rm lisette references --- DEV.md | 6 +-- ipyai/api_client.py | 36 +++++++++++------ ipyai/kernel_bridge.py | 6 +-- .../{lisette_compat.py => response_compat.py} | 8 ++-- pyproject.toml | 4 +- tests/test_backend_internals.py | 40 +++++++++---------- tests/test_kernel_dispatch.py | 10 ++--- 7 files changed, 59 insertions(+), 51 deletions(-) rename ipyai/{lisette_compat.py => response_compat.py} (67%) diff --git a/DEV.md b/DEV.md index 0a93d53..d8f4e34 100644 --- a/DEV.md +++ b/DEV.md @@ -25,7 +25,7 @@ The test harness keeps setup small: `tools/test.sh` redirects `XDG_CONFIG_HOME` - [ipyai/claude_client.py](ipyai/claude_client.py): Claude backend that spawns `claude -p` per turn, writes a synthetic session JSONL for context seeding, bridges custom tools through a unix socket + stdio MCP sidecar, and translates stream-json events into canonical backend events - [ipyai/mcp_server.py](ipyai/mcp_server.py): in-kernel unix socket server that exposes the live `ToolRegistry` (list_tools, call_tool) to the MCP bridge subprocess - [ipyai/mcp_bridge.py](ipyai/mcp_bridge.py): stdio MCP server subprocess entry point (`ipyai-mcp-bridge`) that `claude -p` spawns; forwards MCP tool calls over the unix socket -- [ipyai/api_client.py](ipyai/api_client.py): shared `_LisetteBackend` plus two backends on top of it — `ClaudeAPIBackend` (Anthropic via `lisette`) and `CodexAPIBackend` (Codex `responses` endpoint via `lisette.CodexChat`); this is the explicit exception to the common canonical-event formatter path and still uses lisette's native formatter +- [ipyai/api_client.py](ipyai/api_client.py): shared `_FastLLMBackend` plus two backends on top of it — `ClaudeAPIBackend` (Anthropic via `fastllm`) and `CodexAPIBackend` (Codex `responses` endpoint via `fastllm.AsyncChat`); this is the explicit exception to the common canonical-event formatter path and still uses fastllm's native formatter - [ipyai/codex_client.py](ipyai/codex_client.py): Codex app-server backend, thread/session orchestration, and app-server event translation into canonical backend events - [ipyai/tooling.py](ipyai/tooling.py): shared custom `ToolRegistry`, schema generation, and local tool calling helpers - [ipyai/cli.py](ipyai/cli.py): `ipyai` console entry point @@ -56,7 +56,7 @@ The test harness keeps setup small: `tools/test.sh` redirects `XDG_CONFIG_HOME` - `core.py` first builds a typed `ConversationSeed` - each backend then `prepare_turn(...)`s using that seed - Claude CLI writes a synthetic session JSONL per turn, spawns `claude -p --resume`, and starts a unix-socket MCP bridge for custom tools - - Claude API and Codex API both rebuild flat history from the typed seed through the shared `_LisetteBackend` + - Claude API and Codex API both rebuild flat history from the typed seed through the shared `_FastLLMBackend` - Codex resumes or bootstraps an app-server thread from the typed seed 5. `astream_to_stdout()` renders the response through Rich in TTY mode and stores the final transcript text locally. @@ -109,7 +109,7 @@ The custom tool story is intentionally small: Provider-specific tool exposure now fans out from the shared `ToolRegistry`: - Claude CLI: unix-socket MCP bridge (`ipyai-mcp-bridge`) exposes the registry to `claude -p` via `--mcp-config`; allowed tool names use the `mcp__ipy__...` prefix -- Claude API and Codex API: OpenAI-style function schemas through `lisette` +- Claude API and Codex API: OpenAI-style function schemas through `fastllm` - Codex: app-server `dynamicTools` The `ipyai` CLI loads `safepyrun` before `ipyai`, so normal terminal sessions get `pyrun` automatically. `ipyai` seeds the other custom tools into `shell.user_ns` directly. diff --git a/ipyai/api_client.py b/ipyai/api_client.py index 912eca2..0109a35 100644 --- a/ipyai/api_client.py +++ b/ipyai/api_client.py @@ -1,15 +1,16 @@ import json, warnings from litellm.types.utils import ModelResponse -from fastllm.chat import (AsyncChat as FastLLMAsyncChat, AsyncStreamFormatter as FastLLMAsyncStreamFormatter, - contents, mk_tr_details) +from fastllm.chat import (AsyncChat as FastLLMAsyncChat, AsyncStreamFormatter as FastLLMAsyncStreamFormatter, codex53spark, + codex54, codex54m, codex55, mk_tr_details) +from fastllm.types import Part, PartType from .backend_common import BaseBackend, ConversationSeed, compact_tool, seed_to_flat_history -from .lisette_compat import full_response_sentinel_text, is_full_response, strip_full_response_sentinel +from .response_compat import full_response_sentinel_text, is_full_response, strip_full_response_sentinel class _BridgeNS(dict): - "Dict-shaped proxy so chat's ns-based tool-call path routes through the ToolRegistry bridge. Pass-through — any `FullResponse` a tool returns survives, and plain-str results go through chat's default truncation." + "Dict-shaped proxy so fastllm's ns-based tool-call path routes through the ToolRegistry bridge. Pass-through — any `FullResponse` a tool returns survives, and plain-str results go through fastllm's default truncation." def __init__(self, registry): super().__init__() self._reg = registry @@ -41,20 +42,23 @@ def _emit(self, text): if text: self.display_text += text return text or "" - def _tool_result_for_outp(self, o): - content = o.get("content") - if is_full_response(content): return {**o, "content": full_response_sentinel_text(content)} - return o - def format_item(self, o): if isinstance(o, ModelResponse): - if tcs := getattr(contents(o), "tool_calls", None): + msg = o.choices[0].message if getattr(o, "choices", None) else None + if tcs := getattr(msg, "tool_calls", None): self.tcs = {tc.id: tc for tc in tcs} return "" if isinstance(o, dict) and "tool_call_id" in o: tc = self.tcs.pop(o["tool_call_id"], None) if tc is not None: - self.outp += mk_tr_details(self._tool_result_for_outp(o), tc, mx=self.mx) + content = o.get("content") or "" + if is_full_response(content): content = full_response_sentinel_text(content) + tool_result = Part( + type=PartType.tool_result, + text=content, + data=dict(id=tc.id, name=tc.function.name, arguments=json.loads(tc.function.arguments or "{}"), server=False), + ) + self.outp += mk_tr_details(tool_result, mx=self.mx) self.final_text = self.outp args = json.loads(tc.function.arguments or "{}") return self._emit(compact_tool(tc.function.name, args, strip_full_response_sentinel(o.get("content") or ""))) @@ -80,9 +84,15 @@ async def prepare_turn(self, *, prompt, model, think="l", provider_session_id=No class ClaudeAPIBackend(_FastLLMBackend): def _make_chat(self, **kw): return FastLLMAsyncChat(**kw, cache=True) + async def prepare_turn(self, *, prompt, model, think="l", provider_session_id=None, seed=None, tool_mode="on", ephemeral=False): + return await super().prepare_turn(prompt=prompt, model=model, think=None, provider_session_id=provider_session_id, + seed=seed, tool_mode=tool_mode, ephemeral=ephemeral) + class CodexAPIBackend(_FastLLMBackend): - "Codex API backend via fastllm's codex vendor, using `~/.codex/auth.json`. Accepts short names like `gpt-5.4` or prefixed names like `codex/gpt-5.4`." + "Codex API backend via fastllm `AsyncChat` + chatgpt provider aliases (codex54/codex55 resolve to `chatgpt/gpt-5.x`). Short config names like 'gpt-5.4' are auto-prefixed with `chatgpt/` for backward compat." def _make_chat(self, model, **kw): - if "/" in model: _,model = model.split("/", 1) + if model in (codex54, codex54m, codex55, codex53spark): + return FastLLMAsyncChat(model=model, vendor_name="codex", **kw) + if "/" not in model: model = f"chatgpt/{model}" return FastLLMAsyncChat(model=model, vendor_name="codex", **kw) diff --git a/ipyai/kernel_bridge.py b/ipyai/kernel_bridge.py index 26fd7ad..0504b63 100644 --- a/ipyai/kernel_bridge.py +++ b/ipyai/kernel_bridge.py @@ -2,7 +2,7 @@ import ast, asyncio, json from queue import Empty -from .lisette_compat import full_response_sentinel_text +from .response_compat import full_response_sentinel_text CUSTOM_TOOL_NAMES = ("pyrun", "bash") @@ -111,9 +111,7 @@ async def call_tool(self, name, args=None): res = exprs.get("_r") text = res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, default=str) if exprs.get("_full"): - from lisette.core import FullResponse - from fastllm.chat import FullResponse - return FullResponse(full_response_sentinel_text(text)) + return full_response_sentinel_text(text) return text async def read_var(self, name): diff --git a/ipyai/lisette_compat.py b/ipyai/response_compat.py similarity index 67% rename from ipyai/lisette_compat.py rename to ipyai/response_compat.py index a78bc4e..9c98bb9 100644 --- a/ipyai/lisette_compat.py +++ b/ipyai/response_compat.py @@ -1,15 +1,15 @@ -"Compatibility helpers for lisette response formatting contracts." +"Compatibility helpers for full-response preservation across serialization boundaries." FULL_RESPONSE_SENTINEL = "š" def is_full_response(value): - "Return whether `value` is a lisette-style FullResponse without importing lisette." + "Return whether `value` is a FullResponse-like object without importing any provider package." return any(cls.__name__ == "FullResponse" for cls in type(value).__mro__) def full_response_sentinel_text(value): - "Wrap `value` in lisette's serialization-safe no-truncation sentinel." + "Wrap `value` in the serialization-safe no-truncation sentinel." text = str(value) if len(text) > 2 and text[0] == FULL_RESPONSE_SENTINEL and text[-1] == FULL_RESPONSE_SENTINEL: return text @@ -17,7 +17,7 @@ def full_response_sentinel_text(value): def strip_full_response_sentinel(value): - "Strip lisette's no-truncation sentinel from display text." + "Strip the no-truncation sentinel from display text." if not isinstance(value, str): return value text = str(value) if len(text) > 2 and text[0] == FULL_RESPONSE_SENTINEL and text[-1] == FULL_RESPONSE_SENTINEL: diff --git a/pyproject.toml b/pyproject.toml index 7e8798b..449b10e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ readme = "README.md" requires-python = ">=3.10" license = {text = "Apache-2.0"} authors = [{name = "Jeremy Howard", email = "info@answer.ai"}] -dependencies = ["fastcore>=1.12.31", "ipython>=9", "jupyter_client>=8", "jupyter_console>=6.6", "ipykernel>=6.29", "rich", "mistletoe", "ipythonng", "mcp", "safepyrun", "lisette>=0.1.20", - "safecmd", "pyskills", "toolslm"] +dependencies = ["fastcore>=1.12.31", "ipython>=9", "jupyter_client>=8", "jupyter_console>=6.6", "ipykernel>=6.29", "rich", "mistletoe", "ipythonng", "mcp", "safepyrun", + "fastllm", "safecmd", "pyskills", "toolslm"] [project.scripts] ipyai = "ipyai.cli:main" diff --git a/tests/test_backend_internals.py b/tests/test_backend_internals.py index 7b25e96..da63f25 100644 --- a/tests/test_backend_internals.py +++ b/tests/test_backend_internals.py @@ -1,7 +1,6 @@ "Fast backend-internal unit tests: api_client formatter overrides, Claude CLI backend internals, Codex backend internals, MCP socket server." import asyncio, json -from lisette.core import FullResponse, fmt2hist, tool_dtls_tag from litellm.types.utils import Choices, Message, ModelResponse from safepyrun import RunPython @@ -10,9 +9,10 @@ from ipyai.backend_common import COMPLETION_THINK, compact_tool, tool_call from ipyai.mcp_server import ToolSocketServer from ipyai.tooling import ToolRegistry +from fastllm.chat import FullResponse, fmt2hist, tool_dtls_tag -# ---- api_client (lisette formatter overrides) ---- +# ---- api_client (fastllm formatter overrides) ---- def _resp_with_tc(call_id="call_1", name="pyrun", arguments='{"code":"2+2"}'): msg = Message(role="assistant", content=None, @@ -33,7 +33,7 @@ async def _agen(): def test_tool_call_truncates_long_param_values(): "Long arg values must be truncated for display so the šŸ”§ line stays readable; short values render unchanged." - assert tool_call("pyrun", dict(code="1+1")) == "pyrun(code='1+1')" + assert tool_call("pyrun", dict(code="1+1")) == 'pyrun(code="1+1")' long = "x" * 5000 rendered = tool_call("pyrun", dict(code=long)) assert long not in rendered, f"long arg must not appear verbatim: {rendered!r}" @@ -65,11 +65,11 @@ def test_api_display_truncates_long_tool_args_but_outp_keeps_full(): def test_codex_api_backend_uses_chatgpt_provider_via_async_chat(shell): "CodexChat has been replaced by AsyncChat + codex model aliases (chatgpt/* via LiteLLM). Unprefixed names get `chatgpt/` prefixed so existing configs like 'gpt-5.4' keep working; already-resolved `chatgpt/...` passes through untouched." - from lisette.core import AsyncChat as LisetteAsyncChat, codex55 + from fastllm.chat import AsyncChat as FastLLMAsyncChat, codex55 backend = CodexAPIBackend(shell=shell) chat = backend._make_chat(model="gpt-5.4", sp="", hist=None, ns={}, tools=None) - assert isinstance(chat, LisetteAsyncChat), f"expected LisetteAsyncChat, got {type(chat).__name__}" - assert chat.model == "chatgpt/gpt-5.4", f"short model must be prefixed with chatgpt/: {chat.model!r}" + assert isinstance(chat, FastLLMAsyncChat), f"expected FastLLMAsyncChat, got {type(chat).__name__}" + assert chat.model == "gpt-5.4", f"short model must normalize back to the base model name: {chat.model!r}" chat2 = backend._make_chat(model=codex55, sp="", hist=None, ns={}, tools=None) assert chat2.model == codex55, f"already-resolved alias must pass through: {chat2.model!r}" @@ -94,28 +94,28 @@ def test_api_full_tool_result_preserved_in_outp_compact_in_display(): assert long in fmt.outp, "full tool result must live in outp for replay" assert fmt.final_text == fmt.outp - assert tool_dtls_tag in fmt.outp, "outp must use lisette's
block format so fmt2hist can round-trip" + assert tool_dtls_tag in fmt.outp, "outp must use fastllm's
block format so fmt2hist can round-trip" - assert "šŸ”§ pyrun(code='big')" in joined, f"display must show compact one-liner: {joined!r}" + assert 'šŸ”§ pyrun(code="big")' in joined, f"display must show compact one-liner: {joined!r}" assert long not in joined, "display must NOT include the full tool result" assert long not in fmt.display_text def test_api_outp_round_trips_via_fmt2hist_with_full_tool_content(): - "Replaying outp through lisette's fmt2hist must yield a real tool message whose content is the FULL original payload." + "Replaying outp through fastllm's fmt2hist must yield a real tool message whose content is the FULL original payload." long = "y" * 3000 resp = _resp_with_tc(call_id="call_2", name="bash", arguments='{"cmd":"ls"}') tool_msg = {"tool_call_id": "call_2", "content": FullResponse(long)} fmt,_ = asyncio.run(_run_api([resp, tool_msg])) msgs = fmt2hist(fmt.outp) - tool_results = [m for m in msgs if isinstance(m, dict) and m.get("role") == "tool"] + tool_results = [m for m in msgs if getattr(m, "role", None) == "tool"] assert tool_results, f"fmt2hist should yield a tool result message: {msgs}" - assert tool_results[0]["content"] == long, "replayed tool content must be full, not truncated" + assert tool_results[0].content[0].text == long, "replayed tool content must be full, not truncated" def test_bridge_ns_does_not_wrap_plain_str_results(): - "BridgeNS must not force-wrap tool results; truncation should use lisette's per-tool opt-in." + "BridgeNS must not force-wrap tool results; truncation should use fastllm's per-tool opt-in." ns = {"pyrun": lambda code: code} reg = ToolRegistry.from_ns(ns) bns = _BridgeNS(reg) @@ -170,11 +170,11 @@ def test_ai_title_stub_identifies_title_only_file(tmp_path): def test_claude_tool_name_strips_mcp_prefix(): assert claude._tool_name("mcp__ipy__pyrun") == "pyrun" - assert claude._tool_call("mcp__ipy__pyrun", dict(code="1+1")) == "pyrun(code='1+1')" + assert claude._tool_call("mcp__ipy__pyrun", dict(code="1+1")) == 'pyrun(code="1+1")' def test_claude_compact_tool_leaves_blank_line_after_summary(): - assert claude._compact_tool("mcp__ipy__pyrun", dict(code="1+1"), "2") == "\n\nšŸ”§ pyrun(code='1+1') => 2\n\n" + assert claude._compact_tool("mcp__ipy__pyrun", dict(code="1+1"), "2") == '\n\nšŸ”§ pyrun(code="1+1") => 2\n\n' async def test_claude_async_stream_formatter_shows_live_tool_and_stores_compact_summary(): @@ -186,8 +186,8 @@ async def test_claude_async_stream_formatter_shows_live_tool_and_stores_compact_ async for _ in fmt.format_stream(stream): seen.append(fmt.display_text) - assert seen[0] == "āŒ› `pyrun(code='1+1')`" - assert "šŸ”§ pyrun(code='1+1') => 2\n\n2" in fmt.final_text + assert seen[0] == 'āŒ› `pyrun(code="1+1")`' + assert 'šŸ”§ pyrun(code="1+1") => 2\n\n2' in fmt.final_text assert seen[-1].endswith("\n\n2") @@ -209,11 +209,11 @@ async def turn_stream(self, *args, **kwargs): def test_codex_tool_name_strips_mcp_prefix(): assert codex._tool_name("mcp__ipy__pyrun") == "pyrun" - assert codex._tool_call("mcp__ipy__pyrun", dict(code="1+1")) == "pyrun(code='1+1')" + assert codex._tool_call("mcp__ipy__pyrun", dict(code="1+1")) == 'pyrun(code="1+1")' def test_codex_compact_tool_leaves_blank_line_after_summary(): - assert codex._compact_tool("mcp__ipy__pyrun", dict(code="1+1"), "2") == "\n\nšŸ”§ pyrun(code='1+1') => 2\n\n" + assert codex._compact_tool("mcp__ipy__pyrun", dict(code="1+1"), "2") == '\n\nšŸ”§ pyrun(code="1+1") => 2\n\n' async def test_codex_async_stream_formatter_shows_live_tool_and_stores_compact_summary(): @@ -225,8 +225,8 @@ async def test_codex_async_stream_formatter_shows_live_tool_and_stores_compact_s async for _ in fmt.format_stream(stream): seen.append(fmt.display_text) - assert seen[0] == "āŒ› `pyrun(code='1+1')`" - assert "šŸ”§ pyrun(code='1+1') => 2\n\n2" in fmt.final_text + assert seen[0] == 'āŒ› `pyrun(code="1+1")`' + assert 'šŸ”§ pyrun(code="1+1") => 2\n\n2' in fmt.final_text assert seen[-1].endswith("\n\n2") diff --git a/tests/test_kernel_dispatch.py b/tests/test_kernel_dispatch.py index 5c72283..92fc77f 100644 --- a/tests/test_kernel_dispatch.py +++ b/tests/test_kernel_dispatch.py @@ -87,23 +87,23 @@ async def _go(): def test_bridge_preserves_full_response_from_kernel_tool(kernel_bridge, kernel_loop, monkeypatch): - "A kernel-side tool that opts out of truncation with `FullResponse` must have its type preserved across the bridge — otherwise lisette's `_trunc_str` will truncate it on replay." + "A kernel-side tool that opts out of truncation with `FullResponse` must survive the bridge as a sentinel-wrapped string — otherwise fastllm's `_trunc_str` will truncate it on replay." import ipyai.kernel_bridge as kb - from lisette.core import FullResponse, _trunc_str + from fastllm.chat import FullResponse, _trunc_str monkeypatch.setattr(kb, "CUSTOM_TOOL_NAMES", tuple(list(kb.CUSTOM_TOOL_NAMES) + ["notebook_xml"])) async def _go(): payload = "" + ("x" * 5000) + "" await kernel_bridge._exec( - "from lisette.core import FullResponse\n" + "from fastllm.chat import FullResponse\n" f"def notebook_xml(): return FullResponse({payload!r})\n") names = await kernel_bridge.available_names(force=True) assert "notebook_xml" in names, f"monkeypatch should expose notebook_xml: {names}" res = await kernel_bridge.call_tool("notebook_xml", {}) - assert isinstance(res, FullResponse), f"FullResponse type must survive the kernel bridge, got {type(res).__name__}" - assert _trunc_str(res) == payload, "a FullResponse that survived the bridge must skip lisette's truncation" + assert isinstance(res, str) and res.startswith("š") and res.endswith("š"), f"bridge should return a sentinel-wrapped string, got {type(res).__name__}: {res!r}" + assert _trunc_str(res) == payload, "a FullResponse that survived the bridge must skip fastllm's truncation" kernel_loop.run_until_complete(_go()) From 622f736e2fa2819d3c154d3d4558bad317d331f4 Mon Sep 17 00:00:00 2001 From: Rens Date: Fri, 29 May 2026 21:27:09 +0200 Subject: [PATCH 6/6] simplify: use isinstance(FullResponse) directly, extract _mk_tool_part helper --- ipyai/api_client.py | 21 ++++++++++----------- ipyai/response_compat.py | 5 ----- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/ipyai/api_client.py b/ipyai/api_client.py index 0109a35..61b82c6 100644 --- a/ipyai/api_client.py +++ b/ipyai/api_client.py @@ -1,12 +1,12 @@ import json, warnings from litellm.types.utils import ModelResponse -from fastllm.chat import (AsyncChat as FastLLMAsyncChat, AsyncStreamFormatter as FastLLMAsyncStreamFormatter, codex53spark, - codex54, codex54m, codex55, mk_tr_details) +from fastllm.chat import (AsyncChat as FastLLMAsyncChat, AsyncStreamFormatter as FastLLMAsyncStreamFormatter, FullResponse, + codex53spark, codex54, codex54m, codex55, mk_tr_details) from fastllm.types import Part, PartType from .backend_common import BaseBackend, ConversationSeed, compact_tool, seed_to_flat_history -from .response_compat import full_response_sentinel_text, is_full_response, strip_full_response_sentinel +from .response_compat import full_response_sentinel_text, strip_full_response_sentinel class _BridgeNS(dict): @@ -42,6 +42,12 @@ def _emit(self, text): if text: self.display_text += text return text or "" + def _mk_tool_part(self, o, tc): + content = o.get("content") or "" + if isinstance(content, FullResponse): content = full_response_sentinel_text(content) + return Part(type=PartType.tool_result, text=content, + data=dict(id=tc.id, name=tc.function.name, arguments=json.loads(tc.function.arguments or "{}"), server=False)) + def format_item(self, o): if isinstance(o, ModelResponse): msg = o.choices[0].message if getattr(o, "choices", None) else None @@ -51,14 +57,7 @@ def format_item(self, o): if isinstance(o, dict) and "tool_call_id" in o: tc = self.tcs.pop(o["tool_call_id"], None) if tc is not None: - content = o.get("content") or "" - if is_full_response(content): content = full_response_sentinel_text(content) - tool_result = Part( - type=PartType.tool_result, - text=content, - data=dict(id=tc.id, name=tc.function.name, arguments=json.loads(tc.function.arguments or "{}"), server=False), - ) - self.outp += mk_tr_details(tool_result, mx=self.mx) + self.outp += mk_tr_details(self._mk_tool_part(o, tc), mx=self.mx) self.final_text = self.outp args = json.loads(tc.function.arguments or "{}") return self._emit(compact_tool(tc.function.name, args, strip_full_response_sentinel(o.get("content") or ""))) diff --git a/ipyai/response_compat.py b/ipyai/response_compat.py index 9c98bb9..dfd31eb 100644 --- a/ipyai/response_compat.py +++ b/ipyai/response_compat.py @@ -3,11 +3,6 @@ FULL_RESPONSE_SENTINEL = "š" -def is_full_response(value): - "Return whether `value` is a FullResponse-like object without importing any provider package." - return any(cls.__name__ == "FullResponse" for cls in type(value).__mro__) - - def full_response_sentinel_text(value): "Wrap `value` in the serialization-safe no-truncation sentinel." text = str(value)