diff --git a/README.md b/README.md index ca1fab1..50b257d 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ Additionally, you can filter specific lines from your text and pass them as a pr ## Theming Colors for Code Blocks -Once you used `lmt`, you should have a configuration file (`~/.config/lmt/config.json`) in which you can configure the colors for inline code and code blocks. +`lmt` reads code-theme overrides from `~/.config/lmt/config.json` when the file exists. If the file is missing, defaults are used; create it manually to customize inline code and code-block colors. Here are the styles for the code blocks: diff --git a/changelog.d/20260414_212800_ai.md b/changelog.d/20260414_212800_ai.md new file mode 100644 index 0000000..b75580a --- /dev/null +++ b/changelog.d/20260414_212800_ai.md @@ -0,0 +1,3 @@ +Fixed +----- +* Restore visible streamed output when `Rich` live rendering cannot refresh, such as dumb terminals, and refresh rich terminal output on each chunk. diff --git a/changelog.d/20260414_235600_ai.md b/changelog.d/20260414_235600_ai.md new file mode 100644 index 0000000..2da65bf --- /dev/null +++ b/changelog.d/20260414_235600_ai.md @@ -0,0 +1,3 @@ +Fixed +----- +* Use model-aware streamed `reasoning_effort` for the `gpt-5*` family so `gpt-5.1`, `gpt-5.2`, and `gpt-5-chat-latest` no longer fail during streaming requests. diff --git a/changelog.d/20260415_001500_ai.md b/changelog.d/20260415_001500_ai.md new file mode 100644 index 0000000..6281282 --- /dev/null +++ b/changelog.d/20260415_001500_ai.md @@ -0,0 +1,3 @@ +Fixed +----- +* Stop rewriting `~/.config/lmt/config.json` while reading markdown theme settings so malformed or missing config files no longer get overwritten during normal use. diff --git a/lmterminal/gpt_integration.py b/lmterminal/gpt_integration.py index f299abd..cd104ad 100644 --- a/lmterminal/gpt_integration.py +++ b/lmterminal/gpt_integration.py @@ -21,6 +21,22 @@ def _get_client(api_key: str) -> openai.OpenAI: DEFAULT_MODEL = "gpt-5-nano" +# Keep streamed `gpt-5*` output responsive without sending unsupported values. +STREAMING_REASONING_EFFORT_BY_MODEL_PREFIX = ( + ("gpt-5-chat", None), + ("gpt-5.2", "low"), + ("gpt-5.1", "low"), + ("gpt-5", "minimal"), +) + + +def _streaming_reasoning_effort_for_model(model: str): + """Returns the streaming reasoning effort override for a model, when needed.""" + for prefix, reasoning_effort in STREAMING_REASONING_EFFORT_BY_MODEL_PREFIX: + if model.startswith(prefix): + return reasoning_effort + return None + def format_prompt(system_content, user_content): """Returns a formatted prompt for the OpenAI API.""" @@ -60,14 +76,21 @@ def chatgpt_request( client = _get_client(api_key) + request_kwargs = { + "messages": prompt, + "model": model, + "n": n, + "temperature": temperature, + "stream": stream, + } + if stream: + reasoning_effort = _streaming_reasoning_effort_for_model(model) + if reasoning_effort is not None: + # Lower reasoning effort values stream tokens earlier on `gpt-5*`. + request_kwargs["reasoning_effort"] = reasoning_effort + # Make the API request - response = client.chat.completions.create( - messages=prompt, - model=model, - n=n, - temperature=temperature, - stream=stream, - ) + response = client.chat.completions.create(**request_kwargs) if stream: # Create variables to collect the stream of chunks diff --git a/lmterminal/lib.py b/lmterminal/lib.py index e3d8947..69e8d7e 100644 --- a/lmterminal/lib.py +++ b/lmterminal/lib.py @@ -17,6 +17,8 @@ RESET = "\x1b[0m" DEFAULT_MODEL = "gpt-5-nano" +DEFAULT_CODE_BLOCK_THEME = "monokai" +DEFAULT_INLINE_CODE_THEME = "blue on black" def prepare_and_generate_response( @@ -110,14 +112,17 @@ def load_config() -> dict: Loads the config file. """ config_path = get_config_path() - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.touch(exist_ok=True) + if not config_path.exists(): + return {} try: with open(config_path, "r", encoding="UTF-8") as file: config = json.load(file) - except json.decoder.JSONDecodeError: - config = {} + except (json.decoder.JSONDecodeError, OSError): + return {} + + if not isinstance(config, dict): + return {} return config @@ -127,8 +132,9 @@ def save_config(config: dict) -> None: Saves the config file. """ config_path = get_config_path() - with open(config_path, "w", encoding="UTF-8") as config_path: - json.dump(config, config_path, indent=4) + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="UTF-8") as config_file: + json.dump(config, config_file, indent=4) def get_markdown_code_block_theme() -> str: @@ -136,10 +142,10 @@ def get_markdown_code_block_theme() -> str: Gets the markdown code block theme from the config file. """ config = load_config() - config.setdefault("code_block_theme", "monokai") - save_config(config) - - return config["code_block_theme"] + code_block_theme = config.get("code_block_theme") + if isinstance(code_block_theme, str): + return code_block_theme + return DEFAULT_CODE_BLOCK_THEME def get_markdown_inline_code_theme() -> str: @@ -147,10 +153,10 @@ def get_markdown_inline_code_theme() -> str: Gets the markdown inline code theme from the config file. """ config = load_config() - config.setdefault("inline_code_theme", "blue on black") - save_config(config) - - return config["inline_code_theme"] + inline_code_theme = config.get("inline_code_theme") + if isinstance(inline_code_theme, str): + return inline_code_theme + return DEFAULT_INLINE_CODE_THEME def generate_response( @@ -178,22 +184,32 @@ def generate_response( custom_theme = Theme({"markdown.code": inline_code_theme}) console = Console(theme=custom_theme) - markdown_stream = "" - with Live(markdown_stream, console=console, refresh_per_second=25) as live: - # Allows rich markdown formatted stream in real time - def update_markdown_stream(chunk: str) -> None: - nonlocal markdown_stream - markdown_stream += chunk - if raw: - print(chunk, end="", flush=True) - else: - rich_markdown_stream = Markdown( - markdown_stream, - code_theme=code_block_theme, - ) - live.update(rich_markdown_stream) + use_live_markdown = stream and not raw and console.is_terminal and not console.is_dumb_terminal + update_markdown_stream = None - try: + try: + if use_live_markdown: + markdown_stream = "" + + with Live("", console=console, auto_refresh=False) as live: + # Refresh on each chunk so streaming isn't tied to a background timer. + def update_markdown_stream(chunk: str) -> None: + nonlocal markdown_stream + markdown_stream += chunk + live.update( + Markdown(markdown_stream, code_theme=code_block_theme), + refresh=True, + ) + + content, response_time, response = openai_utils.chatgpt_request( + api_key=api_key, + prompt=prompt, + model=model, + stream=stream, + temperature=temperature, + update_markdown_stream=update_markdown_stream, + ) + else: content, response_time, response = openai_utils.chatgpt_request( api_key=api_key, prompt=prompt, @@ -203,36 +219,36 @@ def update_markdown_stream(chunk: str) -> None: update_markdown_stream=update_markdown_stream, ) - # This is temporary to ensure that the last line always ends with a newline - # This will be removed when refactored - if not content.endswith("\n"): - content += "\n" - ############################# + # This is temporary to ensure that the last line always ends with a newline + # This will be removed when refactored + if not content.endswith("\n"): + content += "\n" + ############################# - if not stream: - print(content, end="") + if not stream: + print(content, end="") - except openai.RateLimitError as error: - click.echo(f"{RED}Error:{RESET} {error}", err=True) - openai_utils.handle_rate_limit_error() - sys.exit(1) + except openai.RateLimitError as error: + click.echo(f"{RED}Error:{RESET} {error}", err=True) + openai_utils.handle_rate_limit_error() + sys.exit(1) - except openai.AuthenticationError: - openai_utils.handle_authentication_error() - sys.stderr.write("\nYou can set your API key by running: ") - sys.stderr.write(f"{BLUE}lmt key set{RESET}\n") - sys.exit(1) + except openai.AuthenticationError: + openai_utils.handle_authentication_error() + sys.stderr.write("\nYou can set your API key by running: ") + sys.stderr.write(f"{BLUE}lmt key set{RESET}\n") + sys.exit(1) - except openai.APIConnectionError as error: - click.echo(f"{RED}Error:{RESET} {error}", err=True) - sys.exit(1) + except openai.APIConnectionError as error: + click.echo(f"{RED}Error:{RESET} {error}", err=True) + sys.exit(1) - except Exception as error: - click.echo(f"{RED}Error:{RESET} {error}", err=True) - sys.exit(1) + except Exception as error: + click.echo(f"{RED}Error:{RESET} {error}", err=True) + sys.exit(1) - else: - return content, response_time, response + else: + return content, response_time, response def display_debug_information(prompt, model, temperature): diff --git a/tests/gpt_integration_test.py b/tests/gpt_integration_test.py index 6cda9cc..f69c04c 100644 --- a/tests/gpt_integration_test.py +++ b/tests/gpt_integration_test.py @@ -70,6 +70,7 @@ def test_chatgpt_request_stream_returns_collected_chunks(monkeypatch): assert response_payload == chunks assert streamed_updates == ["Hel", "", "lo"] assert completions.calls[0]["stream"] is True + assert completions.calls[0]["reasoning_effort"] == "minimal" def test_chatgpt_request_stream_prints_with_flush_when_no_callback(monkeypatch): @@ -101,3 +102,67 @@ def fake_print(*args, **kwargs): (("Hel",), {"end": "", "flush": True}), (("lo",), {"end": "", "flush": True}), ] + assert _completions.calls[0]["reasoning_effort"] == "minimal" + + +def test_chatgpt_request_stream_non_gpt5_does_not_force_reasoning_effort(monkeypatch): + chunks = [ + SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="Hel"))]), + SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="lo"))]), + ] + client, completions = _build_client(iter(chunks)) + monkeypatch.setattr(gpt_integration, "_get_client", lambda _api_key: client) + + generated_text, _response_time, response_payload = gpt_integration.chatgpt_request( + api_key="test-key", + prompt=[{"role": "user", "content": "hello"}], + model="gpt-4o-mini", + stream=True, + update_markdown_stream=lambda _chunk: None, + ) + + assert generated_text == "Hello" + assert response_payload == chunks + assert "reasoning_effort" not in completions.calls[0] + + +def test_streaming_reasoning_effort_for_gpt51_is_low(monkeypatch): + chunks = [ + SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="Hel"))]), + SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="lo"))]), + ] + client, completions = _build_client(iter(chunks)) + monkeypatch.setattr(gpt_integration, "_get_client", lambda _api_key: client) + + generated_text, _response_time, response_payload = gpt_integration.chatgpt_request( + api_key="test-key", + prompt=[{"role": "user", "content": "hello"}], + model="gpt-5.1", + stream=True, + update_markdown_stream=lambda _chunk: None, + ) + + assert generated_text == "Hello" + assert response_payload == chunks + assert completions.calls[0]["reasoning_effort"] == "low" + + +def test_streaming_reasoning_effort_is_skipped_for_gpt5_chat_latest(monkeypatch): + chunks = [ + SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="Hel"))]), + SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="lo"))]), + ] + client, completions = _build_client(iter(chunks)) + monkeypatch.setattr(gpt_integration, "_get_client", lambda _api_key: client) + + generated_text, _response_time, response_payload = gpt_integration.chatgpt_request( + api_key="test-key", + prompt=[{"role": "user", "content": "hello"}], + model="gpt-5-chat-latest", + stream=True, + update_markdown_stream=lambda _chunk: None, + ) + + assert generated_text == "Hello" + assert response_payload == chunks + assert "reasoning_effort" not in completions.calls[0] diff --git a/tests/lib_test.py b/tests/lib_test.py index b494a21..281bfd4 100644 --- a/tests/lib_test.py +++ b/tests/lib_test.py @@ -4,8 +4,13 @@ class DummyLive: + instances = [] + def __init__(self, *_args, **_kwargs): - pass + self.args = _args + self.kwargs = _kwargs + self.updates = [] + self.__class__.instances.append(self) def __enter__(self): return self @@ -14,9 +19,16 @@ def __exit__(self, *_args, **_kwargs): return False def update(self, *_args, **_kwargs): + self.updates.append((_args, _kwargs)) return None +class DummyConsole: + def __init__(self, *, is_terminal=True, is_dumb_terminal=False): + self.is_terminal = is_terminal + self.is_dumb_terminal = is_dumb_terminal + + class DummyRateLimitError(Exception): pass @@ -29,11 +41,20 @@ class DummyAPIConnectionError(Exception): pass -def _prepare_generate_response(monkeypatch): +def _prepare_generate_response(monkeypatch, *, is_terminal=True, is_dumb_terminal=False): monkeypatch.setattr(lib, "get_api_key", lambda: "test-key") monkeypatch.setattr(lib, "get_markdown_code_block_theme", lambda: "monokai") monkeypatch.setattr(lib, "get_markdown_inline_code_theme", lambda: "blue on black") + DummyLive.instances.clear() monkeypatch.setattr(lib, "Live", DummyLive) + monkeypatch.setattr( + lib, + "Console", + lambda **_kwargs: DummyConsole( + is_terminal=is_terminal, + is_dumb_terminal=is_dumb_terminal, + ), + ) def test_generate_response_handles_rate_limit_error(monkeypatch): @@ -103,6 +124,39 @@ def test_generate_response_raw_stream_prints_with_flush(monkeypatch): monkeypatch.setattr(lib.openai, "AuthenticationError", DummyAuthenticationError) monkeypatch.setattr(lib.openai, "APIConnectionError", DummyAPIConnectionError) + printed_calls = [] + + def fake_print(*args, **kwargs): + printed_calls.append((args, kwargs)) + + def fake_chatgpt_request(**kwargs): + assert kwargs["update_markdown_stream"] is None + print("Hel", end="", flush=True) + print("lo", end="", flush=True) + return "Hello", 0.01, object() + + monkeypatch.setattr(lib.openai_utils, "chatgpt_request", fake_chatgpt_request) + monkeypatch.setattr("builtins.print", fake_print) + + content, _response_time, _response = lib.generate_response( + prompt=[{"role": "user", "content": "hello"}], + raw=True, + stream=True, + ) + + assert content == "Hello\n" + assert printed_calls == [ + (("Hel",), {"end": "", "flush": True}), + (("lo",), {"end": "", "flush": True}), + ] + + +def test_generate_response_live_stream_refreshes_each_chunk(monkeypatch): + _prepare_generate_response(monkeypatch) + monkeypatch.setattr(lib.openai, "RateLimitError", DummyRateLimitError) + monkeypatch.setattr(lib.openai, "AuthenticationError", DummyAuthenticationError) + monkeypatch.setattr(lib.openai, "APIConnectionError", DummyAPIConnectionError) + def fake_chatgpt_request(**kwargs): kwargs["update_markdown_stream"]("Hel") kwargs["update_markdown_stream"]("lo") @@ -110,21 +164,71 @@ def fake_chatgpt_request(**kwargs): monkeypatch.setattr(lib.openai_utils, "chatgpt_request", fake_chatgpt_request) + content, _response_time, _response = lib.generate_response( + prompt=[{"role": "user", "content": "hello"}], + raw=False, + stream=True, + ) + + assert content == "Hello\n" + assert len(DummyLive.instances) == 1 + live = DummyLive.instances[0] + assert live.kwargs["auto_refresh"] is False + assert [kwargs for _args, kwargs in live.updates] == [ + {"refresh": True}, + {"refresh": True}, + ] + + +def test_generate_response_dumb_terminal_stream_falls_back_to_plain_output(monkeypatch): + _prepare_generate_response(monkeypatch, is_dumb_terminal=True) + monkeypatch.setattr(lib.openai, "RateLimitError", DummyRateLimitError) + monkeypatch.setattr(lib.openai, "AuthenticationError", DummyAuthenticationError) + monkeypatch.setattr(lib.openai, "APIConnectionError", DummyAPIConnectionError) + printed_calls = [] def fake_print(*args, **kwargs): printed_calls.append((args, kwargs)) + def fake_chatgpt_request(**kwargs): + assert kwargs["update_markdown_stream"] is None + print("Hel", end="", flush=True) + print("lo", end="", flush=True) + return "Hello", 0.01, object() + monkeypatch.setattr("builtins.print", fake_print) + monkeypatch.setattr(lib.openai_utils, "chatgpt_request", fake_chatgpt_request) content, _response_time, _response = lib.generate_response( prompt=[{"role": "user", "content": "hello"}], - raw=True, + raw=False, stream=True, ) assert content == "Hello\n" + assert DummyLive.instances == [] assert printed_calls == [ (("Hel",), {"end": "", "flush": True}), (("lo",), {"end": "", "flush": True}), ] + + +def test_theme_getters_fall_back_without_overwriting_invalid_config(monkeypatch, tmp_path): + config_path = tmp_path / "config.json" + invalid_config = '{\n "code_block_theme": "dracula",\n' + config_path.write_text(invalid_config, encoding="UTF-8") + monkeypatch.setattr(lib, "get_config_path", lambda: config_path) + + assert lib.get_markdown_code_block_theme() == lib.DEFAULT_CODE_BLOCK_THEME + assert lib.get_markdown_inline_code_theme() == lib.DEFAULT_INLINE_CODE_THEME + assert config_path.read_text(encoding="UTF-8") == invalid_config + + +def test_theme_getters_do_not_create_config_file_when_missing(monkeypatch, tmp_path): + config_path = tmp_path / "config.json" + monkeypatch.setattr(lib, "get_config_path", lambda: config_path) + + assert lib.get_markdown_code_block_theme() == lib.DEFAULT_CODE_BLOCK_THEME + assert lib.get_markdown_inline_code_theme() == lib.DEFAULT_INLINE_CODE_THEME + assert not config_path.exists()