use crate::adapter::adapters::support::get_api_key;
use crate::adapter::anthropic::{AnthropicAdapter, AnthropicRequestParts};
use crate::adapter::openai::OpenAIAdapter;
use crate::adapter::{Adapter, AdapterKind, ServiceType, WebRequestData};
use crate::chat::{ChatOptionsSet, ChatRequest, ChatResponse, ChatStreamResponse};
use crate::embed::{EmbedOptionsSet, EmbedRequest, EmbedResponse};
use crate::resolver::{AuthData, Endpoint};
use crate::webc::WebResponse;
use crate::{Error, Headers, ModelIden, Result, ServiceTarget};
use reqwest::RequestBuilder;
use serde_json::json;
use value_ext::JsonValueExt;
pub struct OpenCodeGoAdapter;
impl OpenCodeGoAdapter {
pub const API_KEY_DEFAULT_ENV_NAME: &str = "OPENCODE_GO_API_KEY";
}
enum OpenCodeGoModelKind {
OpenAI,
Anthropic,
}
impl OpenCodeGoModelKind {
fn from_model_name(name: &str) -> Self {
if name.to_lowercase().starts_with("minimax-") {
Self::Anthropic
} else {
Self::OpenAI
}
}
}
impl Adapter for OpenCodeGoAdapter {
const DEFAULT_API_KEY_ENV_NAME: Option<&'static str> = Some(Self::API_KEY_DEFAULT_ENV_NAME);
fn default_auth() -> AuthData {
match Self::DEFAULT_API_KEY_ENV_NAME {
Some(env_name) => AuthData::from_env(env_name),
None => AuthData::None,
}
}
fn default_endpoint() -> Endpoint {
Endpoint::from_static("https://opencode.ai/zen/go/v1/")
}
async fn all_model_names(kind: AdapterKind, endpoint: Endpoint, auth: AuthData) -> Result<Vec<String>> {
OpenAIAdapter::list_model_names_for_end_target(kind, endpoint, auth).await
}
fn get_service_url(model: &ModelIden, _service_type: ServiceType, endpoint: Endpoint) -> Result<String> {
let base_url = endpoint.base_url();
let (_, model_name) = model.model_name.namespace_and_name();
let model_kind = OpenCodeGoModelKind::from_model_name(model_name);
let suffix = match model_kind {
OpenCodeGoModelKind::OpenAI => "chat/completions",
OpenCodeGoModelKind::Anthropic => "messages",
};
Ok(format!("{base_url}{suffix}"))
}
fn to_web_request_data(
target: ServiceTarget,
service_type: ServiceType,
chat_req: ChatRequest,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<WebRequestData> {
let ServiceTarget { endpoint, auth, model } = target;
let (_, model_name) = model.model_name.namespace_and_name();
let model_kind = OpenCodeGoModelKind::from_model_name(model_name);
match model_kind {
OpenCodeGoModelKind::OpenAI => OpenAIAdapter::util_to_web_request_data(
ServiceTarget { endpoint, auth, model },
service_type,
chat_req,
options_set,
None,
),
OpenCodeGoModelKind::Anthropic => {
let model_name = model_name.to_string();
let AnthropicRequestParts {
system,
messages,
tools,
} = AnthropicAdapter::into_anthropic_request_parts(chat_req)?;
let stream = matches!(service_type, ServiceType::ChatStream);
let mut payload = json!({
"model": model_name,
"messages": messages,
"stream": stream,
});
if let Some(system) = system {
payload.x_insert("system", system)?;
}
if let Some(tools) = tools {
payload.x_insert("tools", tools)?;
}
if let Some(temperature) = options_set.temperature() {
payload.x_insert("temperature", temperature)?;
}
if let Some(top_p) = options_set.top_p() {
payload.x_insert("top_p", top_p)?;
}
if !options_set.stop_sequences().is_empty() {
payload.x_insert("stop_sequences", options_set.stop_sequences())?;
}
if let Some(max_tokens) = options_set.max_tokens() {
payload.x_insert("max_tokens", max_tokens)?;
}
let api_key = get_api_key(auth, &model)?;
let headers = Headers::from(("x-api-key".to_string(), api_key));
let url = Self::get_service_url(&model, service_type, endpoint)?;
Ok(WebRequestData { url, headers, payload })
}
}
}
fn to_chat_response(
model_iden: ModelIden,
web_response: WebResponse,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<ChatResponse> {
let (_, model_name) = model_iden.model_name.namespace_and_name();
let model_kind = OpenCodeGoModelKind::from_model_name(model_name);
match model_kind {
OpenCodeGoModelKind::OpenAI => OpenAIAdapter::to_chat_response(model_iden, web_response, options_set),
OpenCodeGoModelKind::Anthropic => AnthropicAdapter::to_chat_response(model_iden, web_response, options_set),
}
}
fn to_chat_stream(
model_iden: ModelIden,
reqwest_builder: RequestBuilder,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<ChatStreamResponse> {
let (_, model_name) = model_iden.model_name.namespace_and_name();
let model_kind = OpenCodeGoModelKind::from_model_name(model_name);
match model_kind {
OpenCodeGoModelKind::OpenAI => OpenAIAdapter::to_chat_stream(model_iden, reqwest_builder, options_set),
OpenCodeGoModelKind::Anthropic => {
AnthropicAdapter::to_chat_stream(model_iden, reqwest_builder, options_set)
}
}
}
fn to_embed_request_data(
_service_target: ServiceTarget,
_embed_req: EmbedRequest,
_options_set: EmbedOptionsSet<'_, '_>,
) -> Result<WebRequestData> {
Err(Error::AdapterNotSupported {
adapter_kind: AdapterKind::OpenCodeGo,
feature: "embeddings".to_string(),
})
}
fn to_embed_response(
_model_iden: ModelIden,
_web_response: WebResponse,
_options_set: EmbedOptionsSet<'_, '_>,
) -> Result<EmbedResponse> {
Err(Error::AdapterNotSupported {
adapter_kind: AdapterKind::OpenCodeGo,
feature: "embeddings".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ServiceTarget;
use crate::adapter::{Adapter, ServiceType};
use crate::chat::{ChatOptions, ChatOptionsSet, ChatRequest};
use crate::embed::{EmbedOptionsSet, EmbedRequest};
use crate::resolver::AuthData;
fn test_target(model_name: &str) -> ServiceTarget {
ServiceTarget {
endpoint: OpenCodeGoAdapter::default_endpoint(),
auth: AuthData::from_single("test-key"),
model: ModelIden::new(AdapterKind::OpenCodeGo, model_name),
}
}
fn make_request(model_name: &str, service_type: ServiceType) -> WebRequestData {
OpenCodeGoAdapter::to_web_request_data(
test_target(model_name),
service_type,
ChatRequest::from_user("hello"),
ChatOptionsSet::default(),
)
.expect("to_web_request_data should succeed")
}
#[test]
fn test_url_openai_path() {
let data = make_request("glm-5", ServiceType::Chat);
assert!(
data.url.ends_with("chat/completions"),
"OpenAI path URL should end with chat/completions: {}",
data.url
);
}
#[test]
fn test_url_minimax_path() {
let data = make_request("minimax-m2.5", ServiceType::Chat);
assert!(
data.url.ends_with("messages"),
"Minimax path URL should end with messages: {}",
data.url
);
}
#[test]
fn test_auth_header_openai() {
let data = make_request("glm-5", ServiceType::Chat);
let auth = data
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("Authorization"))
.map(|(_, v)| v.as_str());
assert_eq!(auth, Some("Bearer test-key"));
}
#[test]
fn test_auth_header_minimax() {
let data = make_request("minimax-m2.5", ServiceType::Chat);
let auth = data
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("Authorization"))
.map(|(_, v)| v.as_str());
assert_eq!(auth, None, "Minimax should not have Authorization header");
let x_key = data
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("x-api-key"))
.map(|(_, v)| v.as_str());
assert_eq!(x_key, Some("test-key"));
}
#[test]
fn test_no_x_api_key_in_openai_path() {
let data = make_request("glm-5", ServiceType::Chat);
let x_key = data
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("x-api-key"))
.map(|(_, v)| v.as_str());
assert_eq!(x_key, None, "OpenAI path should not have x-api-key header");
}
#[test]
fn test_x_api_key_in_minimax_path() {
let data = make_request("minimax-m2.5", ServiceType::Chat);
let x_key = data
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("x-api-key"))
.map(|(_, v)| v.as_str());
assert_eq!(x_key, Some("test-key"));
}
#[test]
fn test_payload_model_name_openai() {
let data = make_request("glm-5", ServiceType::Chat);
let model = data.payload.get("model").and_then(|v| v.as_str());
assert_eq!(model, Some("glm-5"));
}
#[test]
fn test_payload_model_name_minimax() {
let data = make_request("minimax-m2.5", ServiceType::Chat);
let model = data.payload.get("model").and_then(|v| v.as_str());
assert_eq!(model, Some("minimax-m2.5"));
}
#[test]
fn test_payload_messages_array() {
for (name, model_name) in [("OpenAI", "glm-5"), ("Minimax", "minimax-m2.5")] {
let data = make_request(model_name, ServiceType::Chat);
let messages = data.payload.get("messages").and_then(|v| v.as_array());
assert!(messages.is_some(), "{name} payload should have messages array");
let messages = messages.unwrap();
assert!(!messages.is_empty(), "{name} messages array should not be empty");
let last = messages.last().unwrap();
let content = last.get("content").and_then(|v| v.as_str());
assert_eq!(content, Some("hello"), "{name} last message content mismatch");
}
}
#[test]
fn test_payload_stream_false_for_chat() {
for (name, model_name) in [("OpenAI", "glm-5"), ("Minimax", "minimax-m2.5")] {
let data = make_request(model_name, ServiceType::Chat);
let stream = data.payload.get("stream").and_then(|v| v.as_bool());
assert_eq!(stream, Some(false), "{name} stream should be false for Chat");
}
}
#[test]
fn test_payload_stream_true_for_chat_stream() {
for (name, model_name) in [("OpenAI", "glm-5"), ("Minimax", "minimax-m2.5")] {
let data = make_request(model_name, ServiceType::ChatStream);
let stream = data.payload.get("stream").and_then(|v| v.as_bool());
assert_eq!(stream, Some(true), "{name} stream should be true for ChatStream");
}
}
#[test]
fn test_minimax_prefix_case_insensitive() {
let data = make_request("MINIMAX-M2.5", ServiceType::Chat);
assert!(
data.url.ends_with("messages"),
"MINIMAX-M2.5 should route to messages URL: {}",
data.url
);
let data = make_request("Minimax-m2.5", ServiceType::Chat);
assert!(
data.url.ends_with("messages"),
"Minimax-m2.5 should route to messages URL: {}",
data.url
);
}
#[test]
fn test_minimax_payload_with_options() {
let target = test_target("minimax-m2.5");
let chat_options = ChatOptions::default().with_temperature(0.5).with_max_tokens(100);
let options_set = ChatOptionsSet::default().with_chat_options(Some(&chat_options));
let data = OpenCodeGoAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("hello").with_system("system-prompt"),
options_set,
)
.expect("to_web_request_data should succeed");
assert_eq!(
data.payload.get("temperature").and_then(|v| v.as_f64()),
Some(0.5),
"temperature should be present"
);
assert_eq!(
data.payload.get("max_tokens").and_then(|v| v.as_u64()),
Some(100),
"max_tokens should be present"
);
assert_eq!(
data.payload.get("system").and_then(|v| v.as_str()),
Some("system-prompt"),
"system should be present"
);
}
#[test]
fn test_embed_not_supported() {
let result = OpenCodeGoAdapter::to_embed_request_data(
test_target("glm-5"),
EmbedRequest::new("test"),
EmbedOptionsSet::default(),
);
assert!(result.is_err(), "embed should not be supported");
match result.unwrap_err() {
Error::AdapterNotSupported { adapter_kind, feature } => {
assert_eq!(adapter_kind, AdapterKind::OpenCodeGo);
assert_eq!(feature, "embeddings");
}
_ => panic!("Expected AdapterNotSupported error"),
}
}
}