From 946f32a089cab1dd2931c960d203cc9867ebc06e Mon Sep 17 00:00:00 2001 From: Aser Osama <117906529+Aser-Osama@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:38:28 +0300 Subject: [PATCH 1/2] fix(opencode): refresh Go catalog and accept terminal pings --- src/providers/codex/translate/reducer.rs | 25 +++++++ src/providers/opencode/chat.rs | 8 ++- src/providers/opencode/model.rs | 89 ++++++++++++++++++++++-- src/providers/opencode/responses.rs | 39 +++++++++-- src/registry.rs | 1 + 5 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/providers/codex/translate/reducer.rs b/src/providers/codex/translate/reducer.rs index b79102b2..f290b43c 100644 --- a/src/providers/codex/translate/reducer.rs +++ b/src/providers/codex/translate/reducer.rs @@ -340,6 +340,13 @@ pub(crate) fn reduce_upstream_bytes_with_policy( event_count += 1; last_event_type = Some(t.clone()); + // OpenCode Go's Responses endpoint may append a billing/keepalive ping + // after response.completed. It carries no model output and is safe to + // ignore, while all other JSON events after a terminal remain invalid. + if _saw_terminal && t == "ping" { + continue; + } + if _saw_terminal { let message = if t == "response.completed" || t == "response.incomplete" @@ -1447,6 +1454,24 @@ mod tests { assert_eq!(*stop_reason, STOP_END_TURN); } + #[test] + fn completed_terminal_followed_by_ping_remains_valid() { + let upstream = format!( + "{}{}", + sse( + "response.completed", + json!({"response":{"id":"resp_1","status":"completed","usage":{}}}), + ), + sse("ping", json!({"cost":"0"})), + ); + + let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap(); + let Some(ReducerEvent::Finish { stop_reason, .. }) = out.last() else { + panic!("expected Finish"); + }; + assert_eq!(*stop_reason, STOP_END_TURN); + } + #[test] fn standard_responses_max_tokens_takes_priority_over_tool_use() { let upstream = format!( diff --git a/src/providers/opencode/chat.rs b/src/providers/opencode/chat.rs index 59295294..e120c4da 100644 --- a/src/providers/opencode/chat.rs +++ b/src/providers/opencode/chat.rs @@ -350,9 +350,11 @@ fn map_reasoning_effort(req: &MessagesRequest, model: &str) -> anyhow::Result Ok(Some("high".into())), diff --git a/src/providers/opencode/model.rs b/src/providers/opencode/model.rs index 7a7307a3..35d7b953 100644 --- a/src/providers/opencode/model.rs +++ b/src/providers/opencode/model.rs @@ -14,6 +14,10 @@ pub struct ModelSpec { } pub const MODELS: &[ModelSpec] = &[ + ModelSpec { + id: "grok-4.6", + endpoint: EndpointKind::Responses, + }, ModelSpec { id: "grok-4.5", endpoint: EndpointKind::ChatCompletions, @@ -26,6 +30,14 @@ pub const MODELS: &[ModelSpec] = &[ id: "glm-5.2", endpoint: EndpointKind::ChatCompletions, }, + ModelSpec { + id: "glm-5.3-flash", + endpoint: EndpointKind::ChatCompletions, + }, + ModelSpec { + id: "glm-5.3", + endpoint: EndpointKind::ChatCompletions, + }, ModelSpec { id: "glm-5.1", endpoint: EndpointKind::ChatCompletions, @@ -50,6 +62,10 @@ pub const MODELS: &[ModelSpec] = &[ id: "kimi-k2.5", endpoint: EndpointKind::ChatCompletions, }, + ModelSpec { + id: "longcat-2.0", + endpoint: EndpointKind::ChatCompletions, + }, ModelSpec { id: "deepseek-v4-pro", endpoint: EndpointKind::ChatCompletions, @@ -58,6 +74,22 @@ pub const MODELS: &[ModelSpec] = &[ id: "deepseek-v4-flash", endpoint: EndpointKind::ChatCompletions, }, + ModelSpec { + id: "deepseek-flash", + endpoint: EndpointKind::ChatCompletions, + }, + ModelSpec { + id: "deepseek-v4-flash-vision-exp", + endpoint: EndpointKind::ChatCompletions, + }, + ModelSpec { + id: "mimo-v2-pro", + endpoint: EndpointKind::ChatCompletions, + }, + ModelSpec { + id: "mimo-v2-omni", + endpoint: EndpointKind::ChatCompletions, + }, ModelSpec { id: "mimo-v2.5", endpoint: EndpointKind::ChatCompletions, @@ -82,6 +114,10 @@ pub const MODELS: &[ModelSpec] = &[ id: "qwen3.8-max", endpoint: EndpointKind::Messages, }, + ModelSpec { + id: "qwen3.8-flash", + endpoint: EndpointKind::Messages, + }, ModelSpec { id: "qwen3.7-max", endpoint: EndpointKind::Messages, @@ -102,6 +138,26 @@ pub const MODELS: &[ModelSpec] = &[ id: "hy3", endpoint: EndpointKind::ChatCompletions, }, + ModelSpec { + id: "hy4-preview", + endpoint: EndpointKind::ChatCompletions, + }, + ModelSpec { + id: "hy3-preview", + endpoint: EndpointKind::ChatCompletions, + }, + ModelSpec { + id: "muse-spark-1.3-contributor", + endpoint: EndpointKind::Responses, + }, + ModelSpec { + id: "muse-spark-1.2-contributor", + endpoint: EndpointKind::Responses, + }, + ModelSpec { + id: "omen-alpha", + endpoint: EndpointKind::ChatCompletions, + }, ]; pub fn resolve(raw: &str) -> Option { @@ -114,7 +170,7 @@ pub fn advertised_models() -> Vec { for model in MODELS { if !matches!( model.id, - "gpt-5.6-luna" | "grok-4.5" | "kimi-k3" | "kimi-k2.6" + "gpt-5.6-luna" | "grok-4.6" | "grok-4.5" | "kimi-k3" | "kimi-k2.6" ) { result.push(model.id.to_string()); } @@ -142,19 +198,35 @@ mod tests { .iter() .filter(|model| model.endpoint == EndpointKind::Responses) .count(); - assert_eq!(chat, 13); - assert_eq!(messages, 8); - assert_eq!(responses, 1); + assert_eq!(chat, 23); + assert_eq!(messages, 9); + assert_eq!(responses, 4); } #[test] fn refreshed_models_resolve_and_are_advertised() { let advertised = advertised_models(); for (id, endpoint) in [ + ("glm-5.3-flash", EndpointKind::ChatCompletions), + ("glm-5.3", EndpointKind::ChatCompletions), ("glm-5", EndpointKind::ChatCompletions), + ("longcat-2.0", EndpointKind::ChatCompletions), ("kimi-k2.5", EndpointKind::ChatCompletions), + ("deepseek-flash", EndpointKind::ChatCompletions), + ( + "deepseek-v4-flash-vision-exp", + EndpointKind::ChatCompletions, + ), + ("mimo-v2-pro", EndpointKind::ChatCompletions), + ("mimo-v2-omni", EndpointKind::ChatCompletions), ("qwen3.8-max", EndpointKind::Messages), + ("qwen3.8-flash", EndpointKind::Messages), ("qwen3.5-plus", EndpointKind::Messages), + ("hy4-preview", EndpointKind::ChatCompletions), + ("hy3-preview", EndpointKind::ChatCompletions), + ("muse-spark-1.3-contributor", EndpointKind::Responses), + ("muse-spark-1.2-contributor", EndpointKind::Responses), + ("omen-alpha", EndpointKind::ChatCompletions), ] { let qualified = format!("{MODEL_PREFIX}{id}"); assert_eq!( @@ -187,7 +259,14 @@ mod tests { #[test] fn conflicting_provider_ids_are_only_advertised_with_prefix() { let models = advertised_models(); - for id in ["gpt-5.6-luna", "grok-4.5", "kimi-k3", "kimi-k2.6"] { + for id in [ + "gpt-5.6-luna", + "grok-4.6", + "grok-4.5", + "kimi-k3", + "kimi-k2.6", + ] { + assert!(resolve(id).is_some()); assert!(!models.iter().any(|model| model == id)); assert!( models diff --git a/src/providers/opencode/responses.rs b/src/providers/opencode/responses.rs index e7f82e83..21ca5059 100644 --- a/src/providers/opencode/responses.rs +++ b/src/providers/opencode/responses.rs @@ -137,15 +137,19 @@ where done_seen = true; continue; } - if completion_seen || done_seen { - return Some(self.fail_at("protocol", "event_after_completion")); - } let value: serde_json::Value = match serde_json::from_str(data) { Ok(value) => value, Err(_) => return Some(self.fail_at("json", "malformed_event")), }; + let event_type = value.get("type").and_then(serde_json::Value::as_str); + if completion_seen || done_seen { + if event_type == Some("ping") { + continue; + } + return Some(self.fail_at("protocol", "event_after_completion")); + } completion_seen = matches!( - value.get("type").and_then(serde_json::Value::as_str), + event_type, Some("response.completed" | "response.incomplete" | "response.done") ); } @@ -338,4 +342,31 @@ mod tests { String::from_utf8_lossy(&output).contains("OpenCode Go Responses stream is invalid") ); } + + #[tokio::test] + async fn live_stream_accepts_ping_after_completion() { + let upstream = futures_util::stream::iter([Ok::( + Bytes::from_static( + b"data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\nevent: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n", + ), + )]); + let mut state = ResponsesStreamState { + upstream, + decoder: SseDecoder::default(), + translator: LiveStreamTranslator::new("msg_1", "grok-4.6"), + terminal: false, + error_sent: false, + monitor: None, + req_id: "req".into(), + bytes: 0, + chunks: 0, + stream_capture: None, + traffic: None, + }; + + let output = state.next_output().await.expect("completion event"); + let text = String::from_utf8_lossy(&output); + assert!(text.contains("message_stop")); + assert!(!text.contains("OpenCode Go Responses stream is invalid")); + } } diff --git a/src/registry.rs b/src/registry.rs index ae052779..3d9c7271 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -431,6 +431,7 @@ mod tests { for (model, owner) in [ ("gpt-5.6-luna", "codex"), ("grok-4.5", "grok"), + ("grok-4.6", "grok"), ("kimi-k3", "kimi"), ] { assert_eq!( From 5455c7cb273155f6278906ec506b4f68218b3a4c Mon Sep 17 00:00:00 2001 From: Aser Osama <117906529+Aser-Osama@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:46:47 +0300 Subject: [PATCH 2/2] docs(opencode): update routing guidance --- docs/src/content/docs/providers/opencode-go.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/providers/opencode-go.md b/docs/src/content/docs/providers/opencode-go.md index 6a6fa8be..0e48b9d3 100644 --- a/docs/src/content/docs/providers/opencode-go.md +++ b/docs/src/content/docs/providers/opencode-go.md @@ -34,9 +34,9 @@ ANTHROPIC_SMALL_FAST_MODEL=opencode-go/glm-5.2 \ claude --model opencode-go/glm-5.2 ``` -The bare IDs `gpt-5.6-luna`, `grok-4.5`, `kimi-k3`, and `kimi-k2.6` remain -owned by the existing Codex, Grok, or Kimi providers. Prefix those IDs with -`opencode-go/` to select the OpenCode Go version. +The bare IDs `gpt-5.6-luna`, `grok-4.5`, `grok-4.6`, `kimi-k3`, and `kimi-k2.6` +remain owned by the existing Codex, Grok, or Kimi providers. Prefix those IDs +with `opencode-go/` to select the OpenCode Go version. ## Tools and streaming @@ -46,10 +46,9 @@ streamed incrementally and reassembled into Anthropic `tool_use` blocks. Upstream tool behavior remains model-dependent. Models served through the Anthropic-compatible endpoint retain their native -messages stream. GPT 5.6 Luna uses OpenCode Go's Responses endpoint and is -translated to the same Anthropic event stream as other providers. The proxy -handles `/v1/messages/count_tokens` locally and does not send that request to -OpenCode Go. +messages stream. Responses-backed models are translated to the same Anthropic +event stream as other providers. The proxy handles `/v1/messages/count_tokens` +locally and does not send that request to OpenCode Go. ## Configuration