harness-providers + app: dedicated opencode (Zen) provider

OpenAiProvider gains a custom id and a chat-only mode; `OpenAiProvider::opencode`
builds an OpenCode Zen provider (id `opencode`, defaults to Zen's gateway, always
chat-completions so even gpt-*/o* model ids route correctly). Registered from a
`providers.opencode` config block / OPENCODE_API_KEY env, so it coexists with a
real `openai` provider. Models are referenced as `opencode/<model>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 06:59:46 +02:00
co-authored by Claude Opus 4.8
parent 7738ed55b9
commit 230e828b81
3 changed files with 64 additions and 3 deletions
+50 -3
View File
@@ -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!(