Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ surface.
of this narrow route until Codewhale supports per-model wire selection
(#1481 by @seanthefuturegorilla; implementation harvested from PR #773 by
@zhangweiii and PR #1050 by @sternelee).
- Add OpenCode Zen as a separate model-aware API-key provider. Its curated
model catalog selects Responses, Anthropic Messages, or Chat Completions per
model; unsupported Gemini and unknown models fail closed, and Zen missing
credentials never fall through to ChatGPT/Codex OAuth guidance.
- Add TelecomJS TokenHub as a first-class Chat Completions provider with
`[providers.telecomjs]`, `TELECOMJS_API_KEY`, and a key-scoped live
`/v1/models` refresh. Models.dev and provider-specific catalogs remain in
Expand Down
18 changes: 16 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
# `--provider fireworks`, `--provider siliconflow`, `--provider siliconflow-CN`,
# `/provider arcee`, `/provider moonshot`, `/provider qianfan`, `/provider sglang`, `/provider vllm`,
# `/provider ollama`, `/provider huggingface`, `/provider stepfun`, `/provider openmodel`,
# `/provider opencode-go`, `/provider meta`, `/provider xai`) toggle without having to re-enter keys. Top-level
# `/provider opencode-go`, `/provider opencode-zen`, `/provider meta`, `/provider xai`) toggle without having to re-enter keys. Top-level
# `api_key` / `base_url` are
# still read as DeepSeek defaults when `[providers.deepseek]` is absent
# (backward compatibility).
provider = "deepseek" # deepseek | deepseek-cn | deepseek-anthropic | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | openmodel | deepinfra | sakana | longcat | opencode-go | meta | xai
provider = "deepseek" # deepseek | deepseek-cn | deepseek-anthropic | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | openmodel | deepinfra | sakana | longcat | opencode-go | opencode-zen | meta | xai
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
base_url = "https://api.deepseek.com/beta"
# provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com)
Expand Down Expand Up @@ -599,6 +599,20 @@ max_subagents = 10 # optional (1-20)
# base_url = "https://opencode.ai/zen/go/v1"
# model = "deepseek-v4-pro"

# OpenCode Zen (https://opencode.ai/docs/zen/)
# Model-aware gateway: GPT models use Responses, Claude/Qwen use Anthropic
# Messages, and DeepSeek/MiniMax/GLM/Kimi/Grok/free models use Chat Completions.
# Gemini uses a Google-specific protocol that Codewhale does not implement and
# therefore fails closed instead of being sent with the wrong request shape.
# Env vars: OPENCODE_ZEN_API_KEY (preferred), OPENCODE_API_KEY,
# OPENCODE_ZEN_BASE_URL, OPENCODE_ZEN_MODEL
[providers.opencode_zen]
# api_key = "YOUR_OPENCODE_ZEN_API_KEY"
# base_url = "https://opencode.ai/zen/v1"
# model = "gpt-5.5" # Responses default
# model = "claude-sonnet-4-6" # Anthropic Messages example
# model = "deepseek-v4-pro" # Chat Completions example

# Meta Model API / Muse Spark (https://developer.meta.com/ai/)
# OpenAI-compatible Chat Completions route.
# Provider aliases: meta, meta-ai, meta-model-api, muse, muse-spark
Expand Down
34 changes: 32 additions & 2 deletions crates/app-server/src/chat_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ fn resolve_endpoint(

let insecure_skip_tls_verify = provider_cfg.insecure_skip_tls_verify.unwrap_or(false);

let wire_format = provider_meta.wire();
let wire_format = route.protocol();

Ok(ResolvedModelEndpoint {
provider: provider_kind,
Expand Down Expand Up @@ -219,7 +219,11 @@ fn provider_base_url(config: &ConfigToml, provider: ProviderKind) -> String {
fn endpoint_preserves_raw_model_ids(provider: ProviderKind, base_url: &str) -> bool {
matches!(
provider,
ProviderKind::Custom | ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang
ProviderKind::Custom
| ProviderKind::Ollama
| ProviderKind::Vllm
| ProviderKind::Sglang
| ProviderKind::OpencodeZen
) || provider_preserves_custom_base_url_model(provider, base_url)
}

Expand Down Expand Up @@ -926,6 +930,32 @@ api_key = {provider_api_key:?}
}
}

#[test]
fn opencode_zen_app_route_uses_the_resolved_model_protocol() {
let config = ConfigToml {
provider: ProviderKind::OpencodeZen,
..ConfigToml::default()
};
let registry = ModelRegistry::default();

for (model, expected) in [
("gpt-5.5", WireFormat::Responses),
("claude-sonnet-4-6", WireFormat::AnthropicMessages),
("deepseek-v4-pro", WireFormat::ChatCompletions),
] {
let endpoint = resolve_endpoint(&config, &registry, Some(model))
.unwrap_or_else(|error| panic!("{model} should resolve: {error}"));
assert_eq!(endpoint.provider, ProviderKind::OpencodeZen);
assert_eq!(endpoint.model, model);
assert_eq!(endpoint.wire_format, expected);
}

assert!(matches!(
resolve_endpoint(&config, &registry, Some("gemini-3.1-pro")),
Err(RouteError::UnsupportedModelProtocol { .. })
));
}

#[test]
fn root_auth_and_headers_do_not_bleed_across_inferred_providers() {
let mut config = ConfigToml {
Expand Down
21 changes: 21 additions & 0 deletions crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ enum ProviderArg {
LongCat,
#[value(alias = "opencode_go", alias = "opencodego")]
OpencodeGo,
#[value(
alias = "opencode_zen",
alias = "opencodezen",
alias = "zen",
alias = "opencode"
)]
OpencodeZen,
#[value(
alias = "meta-ai",
alias = "meta_ai",
Expand Down Expand Up @@ -120,6 +127,7 @@ impl From<ProviderArg> for ProviderKind {
ProviderArg::Sakana => ProviderKind::Sakana,
ProviderArg::LongCat => ProviderKind::LongCat,
ProviderArg::OpencodeGo => ProviderKind::OpencodeGo,
ProviderArg::OpencodeZen => ProviderKind::OpencodeZen,
ProviderArg::Meta => ProviderKind::Meta,
ProviderArg::Xai => ProviderKind::Xai,
}
Expand Down Expand Up @@ -5328,6 +5336,19 @@ mod tests {
}
}

#[test]
fn opencode_zen_provider_aliases_parse_as_builtin() {
for alias in [
"opencode-zen",
"opencode_zen",
"opencodezen",
"zen",
"opencode",
] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeZen));
}
}

#[test]
fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
Expand Down
24 changes: 24 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,15 @@ pub struct ProvidersToml {
alias = "opencodego"
)]
pub opencode_go: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
alias = "opencode-zen",
alias = "opencodezen",
alias = "zen",
alias = "opencode"
)]
pub opencode_zen: ProviderConfigToml,
#[serde(
default,
skip_serializing_if = "ProviderConfigToml::is_empty",
Expand Down Expand Up @@ -462,6 +471,7 @@ impl ProvidersToml {
ProviderKind::Sakana => &self.sakana,
ProviderKind::LongCat => &self.longcat,
ProviderKind::OpencodeGo => &self.opencode_go,
ProviderKind::OpencodeZen => &self.opencode_zen,
ProviderKind::Meta => &self.meta,
ProviderKind::Xai => &self.xai,
ProviderKind::Telecomjs => &self.telecomjs,
Expand Down Expand Up @@ -503,6 +513,7 @@ impl ProvidersToml {
ProviderKind::Sakana => &mut self.sakana,
ProviderKind::LongCat => &mut self.longcat,
ProviderKind::OpencodeGo => &mut self.opencode_go,
ProviderKind::OpencodeZen => &mut self.opencode_zen,
ProviderKind::Meta => &mut self.meta,
ProviderKind::Xai => &mut self.xai,
ProviderKind::Telecomjs => &mut self.telecomjs,
Expand Down Expand Up @@ -2481,6 +2492,7 @@ impl ConfigToml {
ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
Expand Down Expand Up @@ -3393,6 +3405,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str {
ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
ProviderKind::Meta => DEFAULT_META_MODEL,
ProviderKind::Xai => DEFAULT_XAI_MODEL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
Expand Down Expand Up @@ -3435,6 +3448,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
ProviderKind::Meta => DEFAULT_META_BASE_URL,
ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
Expand Down Expand Up @@ -5239,6 +5253,8 @@ struct EnvRuntimeOverrides {
longcat_model: Option<String>,
opencode_go_base_url: Option<String>,
opencode_go_model: Option<String>,
opencode_zen_base_url: Option<String>,
opencode_zen_model: Option<String>,
meta_base_url: Option<String>,
meta_model: Option<String>,
xai_base_url: Option<String>,
Expand Down Expand Up @@ -5511,6 +5527,12 @@ impl EnvRuntimeOverrides {
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
Expand Down Expand Up @@ -5593,6 +5615,7 @@ impl EnvRuntimeOverrides {
ProviderKind::Sakana => self.sakana_base_url.clone(),
ProviderKind::LongCat => self.longcat_base_url.clone(),
ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
ProviderKind::Meta => self.meta_base_url.clone(),
ProviderKind::Xai => self.xai_base_url.clone(),
ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
Expand Down Expand Up @@ -5628,6 +5651,7 @@ impl EnvRuntimeOverrides {
ProviderKind::Sakana => self.sakana_model.clone(),
ProviderKind::LongCat => self.longcat_model.clone(),
ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
ProviderKind::Meta => self.meta_model.clone(),
ProviderKind::Xai => self.xai_model.clone(),
ProviderKind::Telecomjs => self.telecomjs_model.clone(),
Expand Down
Loading
Loading