Compare commits

...
7 changed files with 597 additions and 13 deletions
+4 -4
View File
@@ -158,7 +158,7 @@ def _show_toml_config(console: Console, config_path: Path) -> None:
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
config_content = config_path.read_text(encoding="utf-8")
syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True)
console.print(Panel(syntax, title="Config File", border_style="cyan"))
else:
@@ -170,7 +170,7 @@ def _show_json_config(console: Console, config_path: Path) -> None:
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
config_content = config_path.read_text(encoding="utf-8")
try:
import tomllib # Python 3.11+
@@ -375,7 +375,7 @@ def set_config(key: str, value: str) -> None:
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
)
if config_path.exists():
doc = tomlkit.parse(config_path.read_text())
doc = tomlkit.parse(config_path.read_text(encoding="utf-8"))
else:
doc = tomlkit.document()
config_path.parent.mkdir(parents=True, exist_ok=True)
@@ -390,7 +390,7 @@ def set_config(key: str, value: str) -> None:
current[parts[-1]] = typed_value
# Write back
config_path.write_text(tomlkit.dumps(doc))
config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
console.print(f"[green]Set[/green] {key} = {value!r}")
+3 -1
View File
@@ -344,7 +344,9 @@ def init(
console.print(f" Looked in: {examples_dir}")
raise SystemExit(1)
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_CONFIG_PATH.write_text(preset_path.read_text())
DEFAULT_CONFIG_PATH.write_text(
preset_path.read_text(encoding="utf-8"), encoding="utf-8"
)
console.print(
f"[green]Preset '{preset}' installed to {DEFAULT_CONFIG_PATH}[/green]"
)
+156 -1
View File
@@ -9,6 +9,7 @@ import json
import logging
import os
import time
import uuid
from collections.abc import AsyncIterator, Sequence
from typing import Any, Dict, List, Tuple
@@ -1305,6 +1306,160 @@ class CloudEngine(InferenceEngine):
if chunk.text:
yield chunk.text
async def _stream_full_google(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""Stream Google text and function-call parts as full chunks."""
if self._google_client is None:
raise EngineConnectionError("Google client not available")
system_text = ""
contents: List[Dict[str, Any]] = []
for message in messages:
if message.role.value == "system":
system_text = message.content
elif message.role.value == "tool":
function_response = {
"function_response": {
"name": message.name or "unknown",
"response": {"result": message.content},
}
}
if (
contents
and contents[-1]["role"] == "user"
and contents[-1]["parts"]
and "function_response" in contents[-1]["parts"][-1]
):
contents[-1]["parts"].append(function_response)
else:
contents.append({"role": "user", "parts": [function_response]})
elif message.role.value == "assistant" and message.tool_calls:
parts: List[Dict[str, Any]] = []
if message.content:
parts.append({"text": message.content})
for tool_call in message.tool_calls:
args = tool_call.arguments
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"input": args}
function_call_part: Dict[str, Any] = {
"function_call": {
"name": tool_call.name,
"args": args if isinstance(args, dict) else {},
}
}
signature = self._thought_sigs.get(tool_call.id)
if signature is not None:
function_call_part["thought_signature"] = signature
parts.append(function_call_part)
contents.append({"role": "model", "parts": parts})
elif message.role.value == "assistant":
contents.append({"role": "model", "parts": [{"text": message.content}]})
else:
contents.append({"role": "user", "parts": [{"text": message.content}]})
from google.genai import types as genai_types
config = genai_types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
if system_text:
config.system_instruction = system_text
tools = kwargs.pop("tools", None)
if tools:
config.tools = [{"function_declarations": _convert_tools_to_google(tools)}]
tool_call_count = 0
stream_id = uuid.uuid4().hex
final_usage: Dict[str, Any] | None = None
for chunk in self._google_client.models.generate_content_stream(
model=model,
contents=contents,
config=config,
):
usage_metadata = getattr(chunk, "usage_metadata", None)
if usage_metadata is not None:
prompt_tokens = getattr(usage_metadata, "prompt_token_count", 0) or 0
completion_tokens = (
getattr(usage_metadata, "candidates_token_count", 0) or 0
)
final_usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
candidates = getattr(chunk, "candidates", None)
parts = []
if candidates:
parts = getattr(candidates[0].content, "parts", []) or []
if parts:
text_found = False
calls: List[Dict[str, Any]] = []
for part in parts:
text = getattr(part, "text", None)
if text:
text_found = True
yield StreamChunk(content=text)
function_call = getattr(part, "function_call", None)
if function_call:
name = getattr(function_call, "name", "")
raw_args = getattr(function_call, "args", {})
args = dict(raw_args) if hasattr(raw_args, "items") else {}
# Gemini emits complete function-call parts, so each part is
# a distinct invocation. The same function may legitimately
# be called more than once in a parallel response.
tool_index = tool_call_count
# The engine is shared across server requests, and saved
# thought signatures are keyed by tool-call ID. Include a
# per-stream nonce so concurrent conversations cannot
# overwrite each other's signatures.
tool_id = f"google_{stream_id}_{tool_index}"
tool_call_count += 1
tool_call = {
"index": tool_index,
"id": tool_id,
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(args),
},
}
calls.append(tool_call)
signature = getattr(part, "thought_signature", None)
if signature is not None:
tool_call["thought_signature"] = signature
self._thought_sigs[tool_id] = signature
if calls:
yield StreamChunk(tool_calls=calls)
if text_found:
continue
try:
text = chunk.text
except (AttributeError, ValueError):
text = None
if text:
yield StreamChunk(content=text)
yield StreamChunk(
finish_reason="tool_calls" if tool_call_count else "stop",
usage=final_usage,
)
async def _stream_openrouter(
self,
messages: Sequence[Message],
@@ -1600,7 +1755,7 @@ class CloudEngine(InferenceEngine):
async for chunk in self._stream_full_anthropic(messages, **kw):
yield chunk
elif _is_google_model(model):
async for chunk in super().stream_full(messages, **kw):
async for chunk in self._stream_full_google(messages, **kw):
yield chunk
else:
async for chunk in self._stream_full_openai(messages, **kw):
+32
View File
@@ -138,6 +138,38 @@ class TestCLI:
content = config_path.read_text()
assert "[engine]" in content
def test_init_preset_uses_utf8_for_config_copy(self, tmp_path: Path) -> None:
"""Preset installation reads and writes shipped TOML as UTF-8."""
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
original_read_text = Path.read_text
original_write_text = Path.write_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path.name == "chat-simple.toml":
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
if path == config_path:
assert kwargs.get("encoding") == "utf-8"
return original_write_text(path, data, *args, **kwargs)
with (
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
mock.patch.object(
Path, "write_text", autospec=True, side_effect=write_text
),
):
result = CliRunner().invoke(cli, ["init", "--preset", "chat-simple"])
assert result.exit_code == 0
assert "lightweight conversational AI" in config_path.read_text(
encoding="utf-8"
)
class TestStartupResilience:
"""Importing the CLI must not force heavy/native deps (#404, #309).
+24 -7
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from pathlib import Path
from unittest import mock
import pytest
from click.testing import CliRunner
@@ -109,18 +110,34 @@ temperature = 0.7
except json.JSONDecodeError:
pytest.fail(f"Output is not valid JSON: {result.output}")
def test_config_show_toml_displays_raw_content(self, tmp_path: Path) -> None:
"""Test that config show toml displays the raw TOML content."""
@pytest.mark.parametrize("output_format", ["toml", "json"])
def test_config_show_uses_utf8_for_config_file(
self, tmp_path: Path, output_format: str
) -> None:
"""Test that config show reads UTF-8 config files explicitly."""
# Create a temporary config file
config_file = tmp_path / "test_config.toml"
config_file.write_text('[engine]\ndefault = "ollama"\n')
result = CliRunner().invoke(
cli, ["config", "show", "toml", "--path", str(config_file)]
config_file.write_text(
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
encoding="utf-8",
)
original_read_text = Path.read_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
with mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text):
result = CliRunner().invoke(
cli, ["config", "show", output_format, "--path", str(config_file)]
)
assert result.exit_code == 0
assert "[engine]" in result.output
if output_format == "toml":
assert "[engine]" in result.output
else:
assert '"engine"' in result.output
assert "ollama" in result.output
def test_config_show_json_displays_parsed_content(self, tmp_path: Path) -> None:
+36
View File
@@ -60,6 +60,42 @@ class TestConfigSet:
assert "vllm" in content
assert "qwen2.5:3b" in content
def test_set_uses_utf8_for_existing_config(self, tmp_path: Path) -> None:
"""config set preserves a UTF-8 config regardless of the system locale."""
config_file = tmp_path / "config.toml"
config_file.write_text(
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
encoding="utf-8",
)
original_read_text = Path.read_text
original_write_text = Path.write_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_write_text(path, data, *args, **kwargs)
with (
mock.patch.dict(os.environ, {"OPENJARVIS_CONFIG": str(config_file)}),
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
mock.patch.object(
Path, "write_text", autospec=True, side_effect=write_text
),
):
result = CliRunner().invoke(
cli, ["config", "set", "engine.default", "vllm"]
)
assert result.exit_code == 0
content = config_file.read_text(encoding="utf-8")
assert "Preset comment — stored as UTF-8" in content
assert "vllm" in content
def test_set_invalid_key_rejected(self, tmp_path: Path) -> None:
"""config set rejects unknown keys."""
config_file = tmp_path / "config.toml"
+342
View File
@@ -3,6 +3,8 @@ and _prepare_anthropic_messages."""
from __future__ import annotations
import sys
from types import ModuleType, SimpleNamespace
from typing import Any, List
from unittest.mock import MagicMock
@@ -63,6 +65,36 @@ def _openai_tool_call_delta(
return tc
class _GoogleConfig:
def __init__(self, **kwargs: Any) -> None:
self.__dict__.update(kwargs)
def _google_stream_chunk(
*parts: Any,
text: str | None = None,
usage_metadata: Any = None,
) -> Any:
candidates = []
if parts:
candidates = [SimpleNamespace(content=SimpleNamespace(parts=list(parts)))]
return SimpleNamespace(
text=text,
candidates=candidates,
usage_metadata=usage_metadata,
)
def _google_types_modules() -> dict[str, ModuleType]:
types = ModuleType("google.genai.types")
types.GenerateContentConfig = _GoogleConfig
genai = ModuleType("google.genai")
genai.types = types
google = ModuleType("google")
google.genai = genai
return {"google": google, "google.genai": genai, "google.genai.types": types}
# ---------------------------------------------------------------------------
# _stream_full_openai tests
# ---------------------------------------------------------------------------
@@ -413,6 +445,316 @@ def test_prepare_anthropic_messages_tool_calls():
assert blocks[1]["input"] == {"city": "Berlin"}
# ---------------------------------------------------------------------------
# _stream_full_google tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_full_google_text_only(monkeypatch: pytest.MonkeyPatch):
"""Google text chunks retain their content and finish normally."""
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(text="Hello"), _google_stream_chunk(text=" world")]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
messages = [Message(role=Role.USER, content="hi")]
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(messages, model="gemini-2.5-flash")
]
assert [chunk.content for chunk in result[:-1]] == ["Hello", " world"]
assert result[-1].finish_reason == "stop"
@pytest.mark.asyncio
async def test_stream_full_google_preserves_tool_calls(monkeypatch: pytest.MonkeyPatch):
"""Google function_call parts become OpenAI-compatible tool call chunks."""
function_call = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
part = SimpleNamespace(
function_call=function_call, text=None, thought_signature=b"sig"
)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(part)]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
messages = [Message(role=Role.USER, content="weather")]
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
messages,
model="gemini-2.5-flash",
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
]
tool_call = result[0].tool_calls[0]
assert tool_call["index"] == 0
assert tool_call["id"].startswith("google_")
assert tool_call["type"] == "function"
assert tool_call["function"] == {
"name": "get_weather",
"arguments": '{"city": "Berlin"}',
}
assert tool_call["thought_signature"] == b"sig"
assert engine._thought_sigs[tool_call["id"]] == b"sig"
assert result[-1].finish_reason == "tool_calls"
config = client.models.generate_content_stream.call_args.kwargs["config"]
assert config.tools == [
{
"function_declarations": [
{
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
}
]
}
]
@pytest.mark.asyncio
async def test_stream_full_google_preserves_mixed_and_multiple_calls(
monkeypatch: pytest.MonkeyPatch,
):
"""Google streams retain mixed text and multiple tool calls."""
weather = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
calendar = SimpleNamespace(name="get_calendar", args={"day": "Monday"})
text_part = SimpleNamespace(text="I'll check.", function_call=None)
weather_part = SimpleNamespace(
function_call=weather, text=None, thought_signature=None
)
calendar_part = SimpleNamespace(
function_call=calendar, text=None, thought_signature=None
)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[
_google_stream_chunk(text_part, weather_part),
_google_stream_chunk(calendar_part),
]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="plan")], model="gemini-2.5-flash"
)
]
assert result[0].content == "I'll check."
weather_call = result[1].tool_calls[0]
calendar_call = result[2].tool_calls[0]
assert weather_call["index"] == 0
assert weather_call["function"] == {
"name": "get_weather",
"arguments": '{"city": "Berlin"}',
}
assert calendar_call["index"] == 1
assert calendar_call["function"] == {
"name": "get_calendar",
"arguments": '{"day": "Monday"}',
}
assert weather_call["id"] != calendar_call["id"]
assert result[-1].finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_stream_full_google_keeps_parallel_same_name_calls_distinct(
monkeypatch: pytest.MonkeyPatch,
):
"""Parallel invocations of one function receive unique indexes and IDs."""
paris = SimpleNamespace(name="get_weather", args={"city": "Paris"})
london = SimpleNamespace(name="get_weather", args={"city": "London"})
parts = [
SimpleNamespace(function_call=paris, text=None, thought_signature=b"sig"),
SimpleNamespace(function_call=london, text=None, thought_signature=None),
]
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(*parts)]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in Paris and London")],
model="gemini-3-flash-preview",
)
]
calls = result[0].tool_calls
assert [call["index"] for call in calls] == [0, 1]
assert calls[0]["id"] != calls[1]["id"]
assert [call["function"]["arguments"] for call in calls] == [
'{"city": "Paris"}',
'{"city": "London"}',
]
@pytest.mark.asyncio
async def test_stream_full_google_ids_are_unique_across_requests(
monkeypatch: pytest.MonkeyPatch,
):
"""Shared engines keep signatures isolated between conversations."""
first_part = SimpleNamespace(
function_call=SimpleNamespace(name="get_weather", args={"city": "Paris"}),
text=None,
thought_signature=b"paris-sig",
)
second_part = SimpleNamespace(
function_call=SimpleNamespace(name="get_weather", args={"city": "London"}),
text=None,
thought_signature=b"london-sig",
)
client = MagicMock()
client.models.generate_content_stream.side_effect = [
iter([_google_stream_chunk(first_part)]),
iter([_google_stream_chunk(second_part)]),
]
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
first = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in Paris")],
model="gemini-3-flash-preview",
)
]
second = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in London")],
model="gemini-3-flash-preview",
)
]
first_id = first[0].tool_calls[0]["id"]
second_id = second[0].tool_calls[0]["id"]
assert first_id != second_id
assert engine._thought_sigs[first_id] == b"paris-sig"
assert engine._thought_sigs[second_id] == b"london-sig"
@pytest.mark.asyncio
async def test_stream_full_google_emits_final_usage(monkeypatch: pytest.MonkeyPatch):
"""Google's final usage metadata is normalized onto the terminal chunk."""
usage = SimpleNamespace(prompt_token_count=12, candidates_token_count=5)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[
_google_stream_chunk(text="Hello"),
_google_stream_chunk(usage_metadata=usage),
]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="hi")],
model="gemini-2.5-flash",
)
]
assert result[-1].finish_reason == "stop"
assert result[-1].usage == {
"prompt_tokens": 12,
"completion_tokens": 5,
"total_tokens": 17,
}
@pytest.mark.asyncio
async def test_stream_full_google_replays_signature_on_part(
monkeypatch: pytest.MonkeyPatch,
):
"""A saved Gemini signature is replayed beside, not inside, function_call."""
client = MagicMock()
client.models.generate_content_stream.return_value = iter([])
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {"google_get_weather_0": b"sig"}
messages = [
Message(role=Role.USER, content="weather"),
Message(
role=Role.ASSISTANT,
content=None,
tool_calls=[
ToolCall(
id="google_get_weather_0",
name="get_weather",
arguments='{"city": "Berlin"}',
)
],
),
Message(role=Role.TOOL, name="get_weather", content='{"temp": 20}'),
]
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
messages, model="gemini-3-flash-preview"
)
]
contents = client.models.generate_content_stream.call_args.kwargs["contents"]
assert contents[1]["parts"] == [
{
"function_call": {
"name": "get_weather",
"args": {"city": "Berlin"},
},
"thought_signature": b"sig",
}
]
assert result[-1].finish_reason == "stop"
# ---------------------------------------------------------------------------
# stream_full routing tests
# ---------------------------------------------------------------------------