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
13 changes: 6 additions & 7 deletions docs/src/content/docs/providers/opencode-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
25 changes: 25 additions & 0 deletions src/providers/codex/translate/reducer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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!(
Expand Down
8 changes: 5 additions & 3 deletions src/providers/opencode/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,11 @@ fn map_reasoning_effort(req: &MessagesRequest, model: &str) -> anyhow::Result<Op
return Ok(None);
};
let id = model.to_ascii_lowercase();
if ["glm-5.2", "glm-5-2", "glm-5p2"]
.iter()
.any(|needle| id.contains(needle))
if [
"glm-5.2", "glm-5-2", "glm-5p2", "glm-5.3", "glm-5-3", "glm-5p3",
]
.iter()
.any(|needle| id.contains(needle))
{
return match effort {
"high" => Ok(Some("high".into())),
Expand Down
89 changes: 84 additions & 5 deletions src/providers/opencode/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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<ModelSpec> {
Expand All @@ -114,7 +170,7 @@ pub fn advertised_models() -> Vec<String> {
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());
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions src/providers/opencode/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}
Expand Down Expand Up @@ -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, OpenCodeError>(
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"));
}
}
1 change: 1 addition & 0 deletions src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down