|
|
|
@@ -8,6 +8,8 @@ use tokio_util::sync::CancellationToken;
|
|
|
|
|
use crate::codec::{openai_chat, openai_responses};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
|
|
|
|
|
/// OpenCode Zen's OpenAI-compatible gateway (chat-completions only).
|
|
|
|
|
const OPENCODE_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
|
|
|
|
|
|
|
|
|
/// Which OpenAI wire format to speak for a given model.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
@@ -33,28 +35,57 @@ fn flavor_for(model: &str) -> ApiFlavor {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct OpenAiProvider {
|
|
|
|
|
id: String,
|
|
|
|
|
api_key: String,
|
|
|
|
|
base_url: String,
|
|
|
|
|
/// When set, always speak chat-completions regardless of model name — required for
|
|
|
|
|
/// OpenAI-compatible gateways (e.g. OpenCode Zen) that don't implement `/responses`.
|
|
|
|
|
chat_only: bool,
|
|
|
|
|
client: reqwest::Client,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OpenAiProvider {
|
|
|
|
|
pub fn new(api_key: impl Into<String>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
id: "openai".to_string(),
|
|
|
|
|
api_key: api_key.into(),
|
|
|
|
|
base_url: DEFAULT_BASE_URL.to_string(),
|
|
|
|
|
chat_only: false,
|
|
|
|
|
client: reqwest::Client::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
id: "openai".to_string(),
|
|
|
|
|
api_key: api_key.into(),
|
|
|
|
|
base_url: base_url.into(),
|
|
|
|
|
chat_only: false,
|
|
|
|
|
client: reqwest::Client::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An OpenCode Zen provider: id `opencode`, chat-completions only, defaulting to Zen's
|
|
|
|
|
/// gateway. Pass `base_url = None` to use the default endpoint.
|
|
|
|
|
pub fn opencode(api_key: impl Into<String>, base_url: Option<String>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
id: "opencode".to_string(),
|
|
|
|
|
api_key: api_key.into(),
|
|
|
|
|
base_url: base_url.unwrap_or_else(|| OPENCODE_BASE_URL.to_string()),
|
|
|
|
|
chat_only: true,
|
|
|
|
|
client: reqwest::Client::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The wire format for `model`, honoring `chat_only`.
|
|
|
|
|
fn flavor(&self, model: &str) -> ApiFlavor {
|
|
|
|
|
if self.chat_only {
|
|
|
|
|
ApiFlavor::Chat
|
|
|
|
|
} else {
|
|
|
|
|
flavor_for(model)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn classify_error(
|
|
|
|
|
status: reqwest::StatusCode,
|
|
|
|
|
body: String,
|
|
|
|
@@ -77,7 +108,7 @@ impl OpenAiProvider {
|
|
|
|
|
#[async_trait]
|
|
|
|
|
impl Provider for OpenAiProvider {
|
|
|
|
|
fn id(&self) -> &str {
|
|
|
|
|
"openai"
|
|
|
|
|
&self.id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
|
|
|
|
@@ -90,7 +121,7 @@ impl Provider for OpenAiProvider {
|
|
|
|
|
req: LlmRequest,
|
|
|
|
|
cancel: CancellationToken,
|
|
|
|
|
) -> Result<LlmEventStream, ProviderError> {
|
|
|
|
|
let (path, body) = match flavor_for(&req.model) {
|
|
|
|
|
let (path, body) = match self.flavor(&req.model) {
|
|
|
|
|
ApiFlavor::Responses => ("/responses", openai_responses::build_request(&req)),
|
|
|
|
|
ApiFlavor::Chat => ("/chat/completions", openai_chat::build_request(&req)),
|
|
|
|
|
};
|
|
|
|
@@ -120,7 +151,7 @@ impl Provider for OpenAiProvider {
|
|
|
|
|
return Err(Self::classify_error(status, body, retry_after));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(match flavor_for(&req.model) {
|
|
|
|
|
Ok(match self.flavor(&req.model) {
|
|
|
|
|
ApiFlavor::Responses => openai_responses::decode(response.bytes_stream()),
|
|
|
|
|
ApiFlavor::Chat => openai_chat::decode(response.bytes_stream()),
|
|
|
|
|
})
|
|
|
|
@@ -151,6 +182,22 @@ mod tests {
|
|
|
|
|
assert_eq!(OpenAiProvider::new("k").id(), "openai");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn opencode_provider_is_chat_only_and_ids_as_opencode() {
|
|
|
|
|
let p = OpenAiProvider::opencode("k", None);
|
|
|
|
|
assert_eq!(p.id(), "opencode");
|
|
|
|
|
assert_eq!(p.base_url, OPENCODE_BASE_URL);
|
|
|
|
|
// Even a gpt-*/o* model must route to chat completions on a chat-only gateway.
|
|
|
|
|
assert_eq!(p.flavor("gpt-5.5"), ApiFlavor::Chat);
|
|
|
|
|
assert_eq!(p.flavor("claude-sonnet-5"), ApiFlavor::Chat);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn opencode_honors_custom_base_url() {
|
|
|
|
|
let p = OpenAiProvider::opencode("k", Some("https://example.test/v1".into()));
|
|
|
|
|
assert_eq!(p.base_url, "https://example.test/v1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classifies_context_overflow_from_400_body() {
|
|
|
|
|
assert!(matches!(
|
|
|
|
|