Skip to content

Commit 0b9f96a

Browse files
committed
feat: add streaming output for /search
Responses now stream token-by-token instead of waiting for the full response. Uses SSE parsing for both Copilot and OpenRouter providers.
1 parent 1594d22 commit 0b9f96a

3 files changed

Lines changed: 72 additions & 11 deletions

File tree

iclaw/github_api.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,31 @@ class UnsupportedModelError(Exception):
4747
pass
4848

4949

50-
def chat(messages, copilot_token, model="gpt-4o", tools=None):
51-
payload = {"model": model, "messages": messages, "stream": False}
50+
def _parse_sse(resp):
51+
"""Parse SSE stream from chat completions API, yielding content chunks."""
52+
for line in resp.iter_lines():
53+
if not line:
54+
continue
55+
line = line.decode("utf-8") if isinstance(line, bytes) else line
56+
if not line.startswith("data: "):
57+
continue
58+
data = line[6:]
59+
if data == "[DONE]":
60+
break
61+
try:
62+
import json
63+
64+
chunk = json.loads(data)
65+
delta = chunk.get("choices", [{}])[0].get("delta", {})
66+
content = delta.get("content")
67+
if content:
68+
yield content
69+
except (ValueError, KeyError, IndexError):
70+
continue
71+
72+
73+
def chat(messages, copilot_token, model="gpt-4o", tools=None, stream=False):
74+
payload = {"model": model, "messages": messages, "stream": stream}
5275
if tools:
5376
payload["tools"] = tools
5477
payload["tool_choice"] = "auto"
@@ -57,6 +80,7 @@ def chat(messages, copilot_token, model="gpt-4o", tools=None):
5780
f"{COPILOT_API_BASE}/chat/completions",
5881
headers={"Authorization": f"Bearer {copilot_token}", **COPILOT_HEADERS},
5982
json=payload,
83+
stream=stream,
6084
)
6185
if not resp.ok:
6286
if resp.status_code == 400 and "unsupported_api_for_model" in resp.text:
@@ -66,4 +90,6 @@ def chat(messages, copilot_token, model="gpt-4o", tools=None):
6690
raise RuntimeError(
6791
f"Chat API error: {resp.status_code} {resp.reason}\n{resp.text}"
6892
)
93+
if stream:
94+
return _parse_sse(resp)
6995
return resp.json()["choices"][0]["message"]

iclaw/main.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@
5353
]
5454

5555

56-
def _chat(provider, token, messages, model, tools=None):
56+
def _chat(provider, token, messages, model, tools=None, stream=False):
5757
if provider == "openrouter":
58-
return openrouter.chat(messages, token, model, tools=tools)
59-
return chat(messages, token, model, tools=tools)
58+
return openrouter.chat(messages, token, model, tools=tools, stream=stream)
59+
return chat(messages, token, model, tools=tools, stream=stream)
6060

6161

6262
def main():
@@ -208,13 +208,22 @@ def main():
208208
):
209209
provider_token = get_copilot_token(github_token)
210210
token_expiry = time.monotonic() + TOKEN_REFRESH_INTERVAL
211-
response_message = _chat(
212-
model_provider, provider_token, messages, current_model, tools=TOOLS
211+
chunks = _chat(
212+
model_provider,
213+
provider_token,
214+
messages,
215+
current_model,
216+
tools=TOOLS,
217+
stream=True,
213218
)
214-
reply = response_message.get("content", "")
219+
print()
220+
reply = ""
221+
for chunk in chunks:
222+
print(chunk, end="", flush=True)
223+
reply += chunk
224+
print("\n")
215225
messages.append({"role": "assistant", "content": reply})
216226
last_reply = reply
217-
log.log_info(f"\n{reply}\n")
218227
except UnsupportedModelError as e:
219228
print(f"Error: {e}", file=sys.stderr)
220229
print("Please select a different model with /model", file=sys.stderr)

iclaw/providers/openrouter.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,31 @@ def get_models(api_key):
2828
return resp.json().get("data", [])
2929

3030

31-
def chat(messages, api_key, model, tools=None):
32-
payload = {"model": model, "messages": messages, "stream": False}
31+
def _parse_sse(resp):
32+
"""Parse SSE stream from chat completions API, yielding content chunks."""
33+
for line in resp.iter_lines():
34+
if not line:
35+
continue
36+
line = line.decode("utf-8") if isinstance(line, bytes) else line
37+
if not line.startswith("data: "):
38+
continue
39+
data = line[6:]
40+
if data == "[DONE]":
41+
break
42+
try:
43+
import json
44+
45+
chunk = json.loads(data)
46+
delta = chunk.get("choices", [{}])[0].get("delta", {})
47+
content = delta.get("content")
48+
if content:
49+
yield content
50+
except (ValueError, KeyError, IndexError):
51+
continue
52+
53+
54+
def chat(messages, api_key, model, tools=None, stream=False):
55+
payload = {"model": model, "messages": messages, "stream": stream}
3356
if tools:
3457
payload["tools"] = tools
3558
payload["tool_choice"] = "auto"
@@ -38,11 +61,14 @@ def chat(messages, api_key, model, tools=None):
3861
f"{OPENROUTER_API_BASE}/chat/completions",
3962
headers=_auth_headers(api_key),
4063
json=payload,
64+
stream=stream,
4165
)
4266
if not resp.ok:
4367
if resp.status_code == 404:
4468
raise UnsupportedModelError(f'Model "{model}" not found on OpenRouter')
4569
raise RuntimeError(
4670
f"Chat API error: {resp.status_code} {resp.reason}\n{resp.text}"
4771
)
72+
if stream:
73+
return _parse_sse(resp)
4874
return resp.json()["choices"][0]["message"]

0 commit comments

Comments
 (0)