M4: dedicated opencode (Zen) provider
Adds a dedicated provider entry for opencode's Zen proxy on top of the existing OpenAI-compatible codec, with its own config wiring in harness-app and harness-core::config::load.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Value, ConfigError> {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user