Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://pygments.org/styles/>

Expand Down
3 changes: 3 additions & 0 deletions changelog.d/20260414_212800_ai.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions changelog.d/20260414_235600_ai.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions changelog.d/20260415_001500_ai.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 30 additions & 7 deletions lmterminal/gpt_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
122 changes: 69 additions & 53 deletions lmterminal/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -127,30 +132,31 @@ 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:
"""
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:
"""
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(
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down
65 changes: 65 additions & 0 deletions tests/gpt_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Loading
Loading