feat(openagents-cli,coder-lite): use OpenResponses API streaming in dev lane

23f70a58216d · AtlantisPleb · · parent 0c7b78300e5c

feat(openagents-cli,coder-lite): use OpenResponses API streaming in dev lane

Add Lane::OpenResponses and implement run_responses_turn in the
openagents-cli runtime. It streams POST /api/v1/responses with
stream: true, parses response.output_text.delta and
response.output_item.done events for tool calls, and loops through
tool steps the same way the thread lane does. No thread is opened, so
this is stateless per turn.

In coder-lite, --dev now automatically routes to the OpenResponses
lane (openresponses or openresponses:<model>), and the --lane
help lists the new values.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/coder-lite/src/main.rs
  • modified crates/openagents-cli/src/runtime.rs

Diff

2 files changed, +304 -11

crates/coder-lite/src/main.rs modified +15 -7

@@ -34,14 +34,16 @@ coder-lite — the OpenAgents coder, in a terminal.

34 34
Usage: coder-lite [options]
35 35
36 36
Options:
37
  --dev              Talk to a server on this machine at http://127.0.0.1:4000.
38
                     Starts one from ../openagents.com if none is running, and
39
                     tolerates one that already is.
37
  --dev              Talk to a server on this machine at http://127.0.0.1:4000
38
                     over the OpenResponses streaming surface. Starts one from
39
                     ../openagents.com if none is running, and tolerates one
40
                     that already is.
40 41
  --lane <name>      Which model answers. `auto` leaves it to the deployment;
41 42
                     `flash` and `pro` are tiers; `local` or `ollama:<model>`
42
                     answers from this machine; any other name is checked
43
                     against GET /api/v1/models and refused if it is not
44
                     served. Defaults to `auto`.
43
                     answers from this machine; `openresponses` or
44
                     `openresponses:<model>` uses the OpenResponses surface;
45
                     any other name is checked against GET /api/v1/models and
46
                     refused if it is not served. Defaults to `auto`.
45 47
  --reasoning <how>  Recorded on the thread as its reasoning effort. Omit to
46 48
                     leave the deployment's own default.
47 49
  -h, --help         Print this and exit.

@@ -65,7 +67,7 @@ Inside the session, `/help` lists the commands and the keys.

65 67
async fn main() -> Result<(), Box<dyn std::error::Error>> {
66 68
    let arguments: Vec<String> = env::args().skip(1).collect();
67 69
    let options = match parse(&arguments) {
68
        Ok(Parsed::Run(options, dev)) => {
70
        Ok(Parsed::Run(mut options, dev)) => {
69 71
            if dev {
70 72
                boot_dev_server().await?;
71 73
                // SAFETY: edition 2024 marks `set_var` unsafe because another

@@ -76,6 +78,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

76 78
                    env::set_var("OPENAGENTS_API_URL", DEV_API_URL);
77 79
                    env::set_var("OPENAGENTS_BASE_URL", DEV_BASE_URL);
78 80
                }
81
                // --dev routes through the OpenResponses streaming surface.
82
                options.lane_name = if options.lane_name == "auto" {
83
                    "openresponses".to_string()
84
                } else {
85
                    format!("openresponses:{}", options.lane_name)
86
                };
79 87
            }
80 88
            options
81 89
        }
crates/openagents-cli/src/runtime.rs modified +289 -4

@@ -189,6 +189,10 @@ pub enum Lane {

189 189
    Pro,
190 190
    /// A model id named directly, checked against `GET /api/v1/models`.
191 191
    Named(String),
192
    /// The OpenResponses surface (`POST /api/v1/responses`), optionally pinned
193
    /// to a model id. No thread is opened; the whole conversation is sent each
194
    /// turn and the response streams as server-sent events.
195
    OpenResponses(Option<String>),
192 196
    /// Ollama on this machine. An empty string means "whatever is installed".
193 197
    Local(String),
194 198
}

@@ -199,16 +203,25 @@ impl Lane {

199 203
    /// settles it and the refusal can name what this deployment serves.
200 204
    #[allow(clippy::should_implement_trait)]
201 205
    pub fn from_str(s: &str) -> Self {
202
        match s.trim().to_lowercase().as_str() {
206
        let lower = s.trim().to_lowercase();
207
        match lower.as_str() {
203 208
            "" | "auto" => Lane::Auto,
204 209
            "ox-alpha" | "ox" | "openagents" => Lane::OxAlpha,
205 210
            "flash" | "coder-flash" | "gemini" | "gemini-flash" | "gemini-3.7-flash" => Lane::Flash,
206 211
            "pro" | "coder-pro" | "gpt-5.6-luna" | "luna" => Lane::Pro,
212
            "openresponses" | "dev" => Lane::OpenResponses(None),
207 213
            "local" | "ollama" => Lane::Local(String::new()),
208
            other if other.starts_with("ollama:") => {
209
                Lane::Local(other.trim_start_matches("ollama:").trim().to_string())
214
            other => {
215
                if let Some(tail) = other.strip_prefix("ollama:") {
216
                    Lane::Local(tail.trim().to_string())
217
                } else if let Some(tail) = other.strip_prefix("openresponses:") {
218
                    Lane::OpenResponses(Some(tail.trim().to_string()))
219
                } else if let Some(tail) = other.strip_prefix("dev:") {
220
                    Lane::OpenResponses(Some(tail.trim().to_string()))
221
                } else {
222
                    Lane::Named(other.to_string())
223
                }
210 224
            }
211
            other => Lane::Named(other.to_string()),
212 225
        }
213 226
    }
214 227

@@ -220,6 +233,8 @@ impl Lane {

220 233
            Lane::Flash => Some(TIERS[0].1),
221 234
            Lane::Pro => Some(TIERS[1].1),
222 235
            Lane::Named(id) => Some(id.as_str()),
236
            Lane::OpenResponses(Some(id)) => Some(id.as_str()),
237
            Lane::OpenResponses(None) => None,
223 238
            // The local lane names its model to Ollama, never to the server.
224 239
            Lane::Local(_) => None,
225 240
        }

@@ -230,6 +245,11 @@ impl Lane {

230 245
        matches!(self, Lane::Local(_))
231 246
    }
232 247
248
    /// Whether this lane talks to the OpenResponses streaming surface.
249
    pub fn is_openresponses(&self) -> bool {
250
        matches!(self, Lane::OpenResponses(_))
251
    }
252
233 253
    /// The tier this lane belongs to: `auto`, `flash`, `pro`, or `local`.
234 254
    ///
235 255
    /// A model id no tier pins has no tier, which is a different answer from

@@ -241,6 +261,7 @@ impl Lane {

241 261
            Lane::Flash => Some("flash"),
242 262
            Lane::Pro => Some("pro"),
243 263
            Lane::Local(_) => Some("local"),
264
            Lane::OpenResponses(_) => Some("openresponses"),
244 265
            Lane::OxAlpha | Lane::Named(_) => None,
245 266
        }
246 267
    }

@@ -253,6 +274,8 @@ impl Lane {

253 274
            Lane::Pro => "Coder Pro".to_string(),
254 275
            Lane::Local(model) if model.is_empty() => "Coder Local".to_string(),
255 276
            Lane::Local(model) => format!("Coder Local ({model})"),
277
            Lane::OpenResponses(None) => "Coder OpenResponses".to_string(),
278
            Lane::OpenResponses(Some(model)) => format!("Coder OpenResponses ({model})"),
256 279
            Lane::OxAlpha => "Coder (ox-alpha)".to_string(),
257 280
            Lane::Named(id) => format!("Coder ({id})"),
258 281
        }

@@ -1256,6 +1279,8 @@ impl CoderRuntimeSession {

1256 1279
1257 1280
        let answered = if self.lane.is_local() {
1258 1281
            self.run_local_turn(&tool_defs, chunk_callback).await
1282
        } else if self.lane.is_openresponses() {
1283
            self.run_responses_turn(&tool_defs, chunk_callback).await
1259 1284
        } else {
1260 1285
            self.run_thread_turn(prompt, &tool_defs, chunk_callback)
1261 1286
                .await

@@ -1441,6 +1466,149 @@ impl CoderRuntimeSession {

1441 1466
        Ok(final_answer)
1442 1467
    }
1443 1468
1469
    async fn run_responses_turn<F>(
1470
        &mut self,
1471
        tool_defs: &[ToolDefinition],
1472
        mut chunk_callback: F,
1473
    ) -> Result<String, Failure>
1474
    where
1475
        F: FnMut(&str) + Send + 'static,
1476
    {
1477
        let mut final_answer = String::new();
1478
        let mut answered = false;
1479
1480
        for _ in 0..MAX_TOOL_STEPS {
1481
            let input = messages_to_responses_input(&self.messages);
1482
            let mut body = serde_json::json!({
1483
                "input": input,
1484
                "tools": tool_defs.iter().map(|t| serde_json::json!({
1485
                    "type": "function",
1486
                    "function": {
1487
                        "name": t.name,
1488
                        "description": t.description,
1489
                        "parameters": t.parameters
1490
                    }
1491
                })).collect::<Vec<_>>(),
1492
                "stream": true
1493
            });
1494
            if let Some(model) = self.lane.model_id() {
1495
                body["model"] = serde_json::Value::String(model.to_string());
1496
            }
1497
1498
            let url = format!("{}/responses", self.api_base);
1499
            let mut headers = HeaderMap::new();
1500
            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
1501
            if let Some(token) = &self.user_token {
1502
                headers.insert(
1503
                    AUTHORIZATION,
1504
                    HeaderValue::from_str(&format!("Bearer {token}"))?,
1505
                );
1506
            }
1507
1508
            let resp = self
1509
                .http
1510
                .post(&url)
1511
                .headers(headers)
1512
                .json(&body)
1513
                .send()
1514
                .await;
1515
            let resp = match resp {
1516
                Ok(r) if r.status().is_success() => r,
1517
                Ok(r) => {
1518
                    let status = r.status();
1519
                    let body = r.text().await.unwrap_or_default();
1520
                    let why = format!("{url} refused the turn: {status} {}", snippet(&body));
1521
                    return Err(self.record_failure(error_code::PROVIDER_FAILED, why).await);
1522
                }
1523
                Err(error) => {
1524
                    let why = format!("{url} could not be reached: {error}");
1525
                    return Err(self.record_failure(error_code::PROVIDER_FAILED, why).await);
1526
                }
1527
            };
1528
1529
            let mut stream = resp.bytes_stream().eventsource();
1530
            let mut step = StepAccumulator::default();
1531
            let mut completed = false;
1532
1533
            while let Some(event) = stream.next().await {
1534
                let event = match event {
1535
                    Ok(event) => event,
1536
                    Err(error) => {
1537
                        let why = format!("the reply from {url} stopped mid-stream: {error}");
1538
                        self.last_usage.add(step.usage);
1539
                        self.last_reasoning.push_str(&step.reasoning);
1540
                        return Err(self.record_failure(error_code::STREAM_BROKEN, why).await);
1541
                    }
1542
                };
1543
1544
                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&event.data) {
1545
                    if event.event == "response.completed" {
1546
                        step.absorb_responses(&value, &mut chunk_callback);
1547
                        completed = true;
1548
                        break;
1549
                    }
1550
                    if event.event == "response.failed" {
1551
                        let message = value
1552
                            .get("response")
1553
                            .and_then(|r| r.get("error"))
1554
                            .and_then(|e| e.get("message"))
1555
                            .and_then(|m| m.as_str())
1556
                            .unwrap_or("response failed");
1557
                        let why = format!("{url} reported a failed response: {message}");
1558
                        self.last_usage.add(step.usage);
1559
                        self.last_reasoning.push_str(&step.reasoning);
1560
                        return Err(self.record_failure(error_code::PROVIDER_FAILED, why).await);
1561
                    }
1562
                    step.absorb_responses(&value, &mut chunk_callback);
1563
                }
1564
            }
1565
1566
            if !completed {
1567
                let why = format!("the reply from {url} ended without response.completed");
1568
                self.last_usage.add(step.usage);
1569
                self.last_reasoning.push_str(&step.reasoning);
1570
                return Err(self.record_failure(error_code::STREAM_BROKEN, why).await);
1571
            }
1572
1573
            self.last_usage.add(step.usage);
1574
            self.last_reasoning.push_str(&step.reasoning);
1575
1576
            if !step.reasoning.trim().is_empty() {
1577
                let thought = ThreadRecord::reasoning(&step.reasoning);
1578
                self.note(vec![thought]).await;
1579
            }
1580
1581
            if step.tool_calls.is_empty() {
1582
                final_answer = step.content;
1583
                self.messages.push(ChatMessage {
1584
                    role: "assistant".to_string(),
1585
                    content: if final_answer.is_empty() {
1586
                        None
1587
                    } else {
1588
                        Some(final_answer.clone())
1589
                    },
1590
                    tool_calls: None,
1591
                    tool_call_id: None,
1592
                });
1593
                answered = true;
1594
                break;
1595
            }
1596
1597
            let ran = self.run_tools(step).await;
1598
            self.last_calls += ran.len();
1599
            self.note(ran).await;
1600
        }
1601
1602
        if !answered {
1603
            let why = format!(
1604
                "the turn used all {MAX_TOOL_STEPS} tool steps without producing an answer; \
1605
                 nothing was returned rather than an empty answer that reads as success"
1606
            );
1607
            return Err(self.record_failure(error_code::MAX_STEPS, why).await);
1608
        }
1609
        Ok(final_answer)
1610
    }
1611
1444 1612
    /// Write a turn's failure to the transcript, then hand back the failure.
1445 1613
    ///
1446 1614
    /// Every exit from a turn that is not the model's own answer goes through

@@ -1860,6 +2028,54 @@ impl StepAccumulator {

1860 2028
        }
1861 2029
    }
1862 2030
2031
    /// One OpenResponses-shaped streaming event.
2032
    fn absorb_responses<F: FnMut(&str)>(&mut self, value: &serde_json::Value, on_chunk: &mut F) {
2033
        let Some(event_type) = value.get("type").and_then(|v| v.as_str()) else {
2034
            return;
2035
        };
2036
2037
        match event_type {
2038
            "response.output_text.delta" => {
2039
                if let Some(delta) = value.get("delta").and_then(|v| v.as_str()) {
2040
                    if !delta.is_empty() {
2041
                        on_chunk(delta);
2042
                        self.content.push_str(delta);
2043
                    }
2044
                }
2045
            }
2046
            "response.reasoning_summary_text.delta" => {
2047
                if let Some(delta) = value.get("delta").and_then(|v| v.as_str()) {
2048
                    self.reasoning.push_str(delta);
2049
                }
2050
            }
2051
            "response.output_item.done" => {
2052
                if let Some(item) = value.get("item") {
2053
                    if item.get("type").and_then(|v| v.as_str()) == Some("function_call") {
2054
                        if let (Some(call_id), Some(name), Some(arguments), Some(index)) = (
2055
                            item.get("call_id").and_then(|v| v.as_str()),
2056
                            item.get("name").and_then(|v| v.as_str()),
2057
                            item.get("arguments").and_then(|v| v.as_str()),
2058
                            value.get("output_index").and_then(|v| v.as_u64()),
2059
                        ) {
2060
                            self.tool_calls
2061
                                .insert(index as usize, (call_id.to_string(), name.to_string(), arguments.to_string()));
2062
                        }
2063
                    }
2064
                }
2065
            }
2066
            "response.completed" => {
2067
                if let Some(usage) = value.get("response").and_then(|r| r.get("usage")) {
2068
                    self.usage.add(TurnUsage {
2069
                        prompt_tokens: field(usage, "input_tokens"),
2070
                        completion_tokens: field(usage, "output_tokens"),
2071
                        total_tokens: field(usage, "total_tokens"),
2072
                    });
2073
                }
2074
            }
2075
            _ => {}
2076
        }
2077
    }
2078
1863 2079
    /// One Ollama-shaped streaming line.
1864 2080
    ///
1865 2081
    /// The counts arrive on the `done` line, the model's scratch work under

@@ -1930,6 +2146,75 @@ fn field(value: &serde_json::Value, key: &str) -> u64 {

1930 2146
    value.get(key).and_then(|v| v.as_u64()).unwrap_or(0)
1931 2147
}
1932 2148
2149
/// Convert the session's message list to OpenResponses input items.
2150
///
2151
/// The OpenResponses surface takes a flat list of `input` items — `user`,
2152
/// `assistant`, and `system` messages plus `function_call` and
2153
/// `function_call_output` replay items — so a multi-turn conversation can be
2154
/// sent again on each stateless request.
2155
fn messages_to_responses_input(messages: &[ChatMessage]) -> Vec<serde_json::Value> {
2156
    let mut items = Vec::new();
2157
    for message in messages {
2158
        match message.role.as_str() {
2159
            "system" | "user" => {
2160
                if let Some(content) = &message.content {
2161
                    items.push(serde_json::json!({
2162
                        "role": message.role,
2163
                        "content": content
2164
                    }));
2165
                }
2166
            }
2167
            "assistant" => {
2168
                let content = message.content.as_deref().unwrap_or("");
2169
                if let Some(calls) = &message.tool_calls {
2170
                    for (i, call) in calls.iter().enumerate() {
2171
                        let call_id = call
2172
                            .get("id")
2173
                            .and_then(|v| v.as_str())
2174
                            .unwrap_or("")
2175
                            .to_string();
2176
                        let function = call.get("function").unwrap_or(&serde_json::Value::Null);
2177
                        let name = function
2178
                            .get("name")
2179
                            .and_then(|v| v.as_str())
2180
                            .unwrap_or("")
2181
                            .to_string();
2182
                        let arguments = function
2183
                            .get("arguments")
2184
                            .and_then(|v| v.as_str())
2185
                            .unwrap_or("{}")
2186
                            .to_string();
2187
                        let text = if i == 0 { content } else { "" };
2188
                        items.push(serde_json::json!({
2189
                            "type": "function_call",
2190
                            "content": text,
2191
                            "call_id": call_id,
2192
                            "name": name,
2193
                            "arguments": arguments
2194
                        }));
2195
                    }
2196
                } else if !content.is_empty() {
2197
                    items.push(serde_json::json!({
2198
                        "role": "assistant",
2199
                        "content": content
2200
                    }));
2201
                }
2202
            }
2203
            "tool" => {
2204
                if let Some(content) = &message.content {
2205
                    items.push(serde_json::json!({
2206
                        "type": "function_call_output",
2207
                        "call_id": message.tool_call_id.as_deref().unwrap_or(""),
2208
                        "output": content
2209
                    }));
2210
                }
2211
            }
2212
            _ => {}
2213
        }
2214
    }
2215
    items
2216
}
2217
1933 2218
/// One message in the shape Ollama's chat API takes back.
1934 2219
///
1935 2220
/// The differences from the proxy's shape are small and all load-bearing:

This page updates live while a promote is in flight · changelog