diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index 64e964d..b339749 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -147,6 +147,19 @@ impl EngineHandle { }; providers.register(Arc::new(provider)); } + // OpenCode Zen: OpenAI-compatible chat gateway under its own `opencode` provider id, + // so it coexists with a real `openai` provider. `base_url` defaults to Zen's endpoint. + if let Some(key) = config + .providers + .get("opencode") + .and_then(|p| p.api_key.clone()) + { + let base_url = config + .providers + .get("opencode") + .and_then(|p| p.base_url.clone()); + providers.register(Arc::new(OpenAiProvider::opencode(key, base_url))); + } Self::build(cwd, store, config, providers) } diff --git a/crates/harness-core/src/config/load.rs b/crates/harness-core/src/config/load.rs index d9988ed..99709e6 100644 --- a/crates/harness-core/src/config/load.rs +++ b/crates/harness-core/src/config/load.rs @@ -29,6 +29,7 @@ const ENV_CONFIG_PATH: &str = "AI_HARNESS_CONFIG"; const PROVIDER_ENV_KEYS: &[(&str, &str)] = &[ ("anthropic", "ANTHROPIC_API_KEY"), ("openai", "OPENAI_API_KEY"), + ("opencode", "OPENCODE_API_KEY"), ]; fn read_jsonc(path: &Path) -> Result { diff --git a/crates/harness-providers/src/openai.rs b/crates/harness-providers/src/openai.rs index 9961d17..4e7a1aa 100644 --- a/crates/harness-providers/src/openai.rs +++ b/crates/harness-providers/src/openai.rs @@ -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) -> 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, base_url: impl Into) -> 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, base_url: Option) -> 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, ProviderError> { @@ -90,7 +121,7 @@ impl Provider for OpenAiProvider { req: LlmRequest, cancel: CancellationToken, ) -> Result { - 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!(