Skip to content
Merged
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ Configuration values can be set from config file, env var or command line flag,
| Safe yes | `safe_yes: true` | `RUNPROMPT_SAFE_YES=1` | `--safe-yes` |
| Verbose | `verbose: true` | `RUNPROMPT_VERBOSE=1` | `--verbose` |
| Chat | `chat: true` | `RUNPROMPT_CHAT=1` | `--chat` |
| LLM timeout | `timeout: 120` | `RUNPROMPT_TIMEOUT=120` | `--timeout 120` |

### API keys

Expand All @@ -572,6 +573,7 @@ model: openai/gpt-4o
default_model: anthropic/claude-sonnet-4-20250514 # fallback if model not set anywhere
cache: true
safe_yes: true
timeout: 120
tool_path:
- ./tools
- /shared/tools
Expand Down
27 changes: 25 additions & 2 deletions runprompt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ CONFIG_KEYS = {
"model", "default_model", "tool_path", "base_url", "ollama_base_url", "ollama_api_base",
"openai_base_url", "openai_api_base", "cache", "cache_dir",
"safe_yes", "verbose", "anthropic_api_key", "openai_api_key",
"google_api_key", "openrouter_api_key", "chat", "chat_history", "history_file",
"google_api_key", "openrouter_api_key", "chat", "chat_history", "history_file", "timeout",
}

PROVIDERS = {
Expand Down Expand Up @@ -178,6 +178,26 @@ def init_config(args):
CONFIG["args"] = args_dict


def get_timeout():
"""Get configured LLM request timeout in seconds."""
timeout = get_conf("timeout", TIMEOUT)
if isinstance(timeout, bool):
print("%sInvalid timeout: %s%s" % (RED, timeout, RESET), file=sys.stderr)
print("Timeout must be a positive number of seconds.", file=sys.stderr)
sys.exit(1)
try:
timeout = float(timeout)
except (TypeError, ValueError):
print("%sInvalid timeout: %s%s" % (RED, timeout, RESET), file=sys.stderr)
print("Timeout must be a positive number of seconds.", file=sys.stderr)
sys.exit(1)
if timeout <= 0:
print("%sInvalid timeout: %s%s" % (RED, timeout, RESET), file=sys.stderr)
print("Timeout must be a positive number of seconds.", file=sys.stderr)
sys.exit(1)
return timeout


# === MAIN ENTRY POINT ===


Expand Down Expand Up @@ -525,6 +545,7 @@ Config file example:
- /shared/tools
cache: true
safe_yes: true
timeout: 120
openai_api_key: sk-...

Environment:
Expand Down Expand Up @@ -600,6 +621,8 @@ def parse_args(args):
metavar="PATH", help="Add directory to tool import path")
parser.add_argument("--chat", action="store_true",
help="Interactive chat mode: prompt for input after each response")
parser.add_argument("--timeout", type=float, metavar="SECONDS",
help="LLM request timeout in seconds")
parser.add_argument("--read", "--file", action="append", default=[],
metavar="FILE",
help="Read file(s) into context (supports globs)")
Expand Down Expand Up @@ -1150,7 +1173,7 @@ def make_request(url, api_key, model, messages, output_config, provider, tools=N
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
start_time = time.time()
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
with urllib.request.urlopen(req, timeout=get_timeout()) as resp:
response_body = resp.read().decode("utf-8")
elapsed = time.time() - start_time
log("Response: %s" % response_body)
Expand Down
71 changes: 71 additions & 0 deletions tests/test-config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
#!/usr/bin/env python3
"""Test configuration cascade functionality."""
import importlib.machinery
import importlib.util
import subprocess
import os
import json
import shutil
import tempfile
import threading
import time
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler

MOCK_PORT = 18820
Expand Down Expand Up @@ -41,6 +44,30 @@ def log_message(self, format, *args):
pass


class DummyResponse:
def __enter__(self):
return self

def __exit__(self, exc_type, exc, tb):
pass

def read(self):
response = {
"choices": [{
"message": {"content": "OK"}
}]
}
return json.dumps(response).encode("utf-8")


def load_runprompt():
loader = importlib.machinery.SourceFileLoader("runprompt_module", RUNPROMPT)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module


def run_server(server):
server.serve_forever()

Expand Down Expand Up @@ -76,6 +103,48 @@ def clean_env():
return env


def test_timeout_cascade_for_provider_request():
"""Test that timeout config reaches the provider request."""
module = load_runprompt()
captured = []
original_urlopen = urllib.request.urlopen

def fake_urlopen(req, **kwargs):
captured.append(kwargs.get("timeout"))
return DummyResponse()

try:
urllib.request.urlopen = fake_urlopen
cases = [
({}, {}, {}, 120.0, "default"),
({"timeout": 30}, {}, {}, 30.0, "config file"),
({"timeout": 30}, {"timeout": 45}, {}, 45.0, "env"),
({"timeout": 30}, {"timeout": 45}, {"timeout": 60}, 60.0,
"CLI args"),
]
for files, env, args, expected, source in cases:
module.CONFIG["files"] = files
module.CONFIG["env"] = env
module.CONFIG["args"] = args
module.make_request("https://example.test/chat/completions",
"test-key", "gpt-4o",
[{"role": "user", "content": "hi"}],
{}, "openai")
assert captured[-1] == expected, \
"Expected timeout from %s, got: %s" % (source, captured[-1])
finally:
urllib.request.urlopen = original_urlopen


def test_timeout_cli_flag():
"""Test that --timeout is parsed as a config value."""
module = load_runprompt()
parsed = module.parse_args(["--timeout", "12.5", "test.prompt"])
module.init_config(parsed)
assert module.get_conf("timeout") == 12.5, \
"Expected parsed timeout, got: %s" % module.get_conf("timeout")


def test_config_file_model():
"""Test that model can be set from config file."""
server = start_server(MOCK_PORT)
Expand Down Expand Up @@ -320,6 +389,8 @@ def test_tool_path_from_config():


if __name__ == '__main__':
test("timeout cascade for provider request", test_timeout_cascade_for_provider_request)
test("timeout CLI flag", test_timeout_cli_flag)
test("config file model", test_config_file_model)
test("env overrides config file", test_env_overrides_config_file)
test("CLI overrides env", test_cli_overrides_env)
Expand Down
Loading