M4: Multiagent #5
@@ -147,6 +147,19 @@ impl EngineHandle {
|
|||||||
};
|
};
|
||||||
providers.register(Arc::new(provider));
|
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)
|
Self::build(cwd, store, config, providers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const ENV_CONFIG_PATH: &str = "AI_HARNESS_CONFIG";
|
|||||||
const PROVIDER_ENV_KEYS: &[(&str, &str)] = &[
|
const PROVIDER_ENV_KEYS: &[(&str, &str)] = &[
|
||||||
("anthropic", "ANTHROPIC_API_KEY"),
|
("anthropic", "ANTHROPIC_API_KEY"),
|
||||||
("openai", "OPENAI_API_KEY"),
|
("openai", "OPENAI_API_KEY"),
|
||||||
|
("opencode", "OPENCODE_API_KEY"),
|
||||||
];
|
];
|
||||||
|
|
||||||
fn read_jsonc(path: &Path) -> Result<Value, ConfigError> {
|
fn read_jsonc(path: &Path) -> Result<Value, ConfigError> {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use crate::codec::{openai_chat, openai_responses};
|
use crate::codec::{openai_chat, openai_responses};
|
||||||
|
|
||||||
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
|
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.
|
/// Which OpenAI wire format to speak for a given model.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -33,28 +35,57 @@ fn flavor_for(model: &str) -> ApiFlavor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct OpenAiProvider {
|
pub struct OpenAiProvider {
|
||||||
|
id: String,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
base_url: 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,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenAiProvider {
|
impl OpenAiProvider {
|
||||||
pub fn new(api_key: impl Into<String>) -> Self {
|
pub fn new(api_key: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: "openai".to_string(),
|
||||||
api_key: api_key.into(),
|
api_key: api_key.into(),
|
||||||
base_url: DEFAULT_BASE_URL.to_string(),
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
|
chat_only: false,
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
|
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: "openai".to_string(),
|
||||||
api_key: api_key.into(),
|
api_key: api_key.into(),
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
|
chat_only: false,
|
||||||
client: reqwest::Client::new(),
|
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(
|
fn classify_error(
|
||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
body: String,
|
body: String,
|
||||||
@@ -77,7 +108,7 @@ impl OpenAiProvider {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Provider for OpenAiProvider {
|
impl Provider for OpenAiProvider {
|
||||||
fn id(&self) -> &str {
|
fn id(&self) -> &str {
|
||||||
"openai"
|
&self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
|
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
|
||||||
@@ -90,7 +121,7 @@ impl Provider for OpenAiProvider {
|
|||||||
req: LlmRequest,
|
req: LlmRequest,
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
) -> Result<LlmEventStream, ProviderError> {
|
) -> 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::Responses => ("/responses", openai_responses::build_request(&req)),
|
||||||
ApiFlavor::Chat => ("/chat/completions", openai_chat::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));
|
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::Responses => openai_responses::decode(response.bytes_stream()),
|
||||||
ApiFlavor::Chat => openai_chat::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");
|
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]
|
#[test]
|
||||||
fn classifies_context_overflow_from_400_body() {
|
fn classifies_context_overflow_from_400_body() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
Reference in New Issue
Block a user