use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct CodewhaleRouteIdentity {
pub(crate) provider_id: String,
pub(crate) provider_label: String,
pub(crate) model: String,
pub(crate) base_url: String,
pub(crate) protocol: WireProtocol,
pub(crate) api_key_env: Option<String>,
pub(crate) keyless_local: bool,
pub(crate) reasoning_effort: Option<String>,
pub(crate) sandbox_mode: Option<String>,
pub(crate) approval_policy: Option<String>,
pub(crate) yolo: bool,
pub(crate) workspace: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum WireProtocol {
ChatCompletions,
Responses,
AnthropicMessages,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum DshPermissionMode {
ReadOnly,
WorkspaceWrite,
DangerFullAccess,
}
impl DshPermissionMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum DshAdapter {
DeepseekNative,
PiAiOpenAiCompatible { route_id: String },
Unsupported { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct MappedIdentity {
pub(crate) source: CodewhaleRouteIdentity,
pub(crate) adapter: DshAdapter,
pub(crate) dsh_reasoning_effort: Option<String>,
pub(crate) permission_mode: DshPermissionMode,
pub(crate) disclosures: Vec<String>,
}
impl MappedIdentity {
pub(crate) fn mappable(&self) -> bool {
!matches!(self.adapter, DshAdapter::Unsupported { .. })
}
pub(crate) fn dsh_provider(&self) -> Option<&str> {
match &self.adapter {
DshAdapter::DeepseekNative => Some("deepseek-official"),
DshAdapter::PiAiOpenAiCompatible { route_id } => Some(route_id),
DshAdapter::Unsupported { .. } => None,
}
}
}
pub(crate) fn dsh_reasoning_effort(effort: Option<&str>) -> Option<&'static str> {
match effort.map(|e| e.trim().to_ascii_lowercase()).as_deref() {
None | Some("") => None,
Some("off" | "none" | "disabled" | "false") => Some("off"),
Some("minimal" | "low" | "medium" | "mid" | "high" | "auto") => Some("high"),
Some("xhigh" | "max" | "maximum" | "highest" | "ultra" | "ultracode") => Some("max"),
Some(_) => None,
}
}
pub(crate) fn permission_mode_for(
identity: &CodewhaleRouteIdentity,
allow_full_access: bool,
) -> (DshPermissionMode, Option<String>) {
let sandbox = identity
.sandbox_mode
.as_deref()
.map(|s| s.trim().to_ascii_lowercase());
let approval = identity
.approval_policy
.as_deref()
.map(|s| s.trim().to_ascii_lowercase());
let codewhale_full = identity.yolo
|| matches!(
sandbox.as_deref(),
Some("danger-full-access" | "external-sandbox")
);
if matches!(sandbox.as_deref(), Some("read-only")) {
return (DshPermissionMode::ReadOnly, None);
}
if codewhale_full {
if allow_full_access {
return (
DshPermissionMode::DangerFullAccess,
Some(
"DSH danger-full-access mirrors Codewhale full access; DSH will not ask before file effects."
.to_string(),
),
);
}
return (
DshPermissionMode::WorkspaceWrite,
Some(
"Codewhale runs with full access, but the DSH overlay stays at workspace-write; pass --allow-full-access to mirror it."
.to_string(),
),
);
}
let note = match approval.as_deref() {
Some("never" | "deny" | "denied") => Some(
"Codewhale approval policy is `never`; DSH keeps its own ask-before-effects policy at workspace-write."
.to_string(),
),
_ => None,
};
(DshPermissionMode::WorkspaceWrite, note)
}
fn base_url_is_structural(url: &str) -> Result<(), String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err("empty base URL".to_string());
}
let Some((scheme, rest)) = trimmed.split_once("://") else {
return Err("base URL has no scheme".to_string());
};
if !matches!(scheme, "http" | "https") {
return Err(format!("unsupported base URL scheme `{scheme}`"));
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
if authority.contains('@') {
return Err("base URL embeds userinfo; refusing to copy it".to_string());
}
if rest.contains('?') || rest.contains('#') {
return Err("base URL carries a query/fragment; refusing to copy it".to_string());
}
Ok(())
}
fn route_id_for(provider_id: &str) -> String {
let mut out = String::from("codewhale-");
for ch in provider_id.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_end_matches('-').to_string()
}
pub(crate) fn map_identity(
identity: &CodewhaleRouteIdentity,
allow_full_access: bool,
) -> MappedIdentity {
let mut disclosures = Vec::new();
let (permission_mode, note) = permission_mode_for(identity, allow_full_access);
if let Some(note) = note {
disclosures.push(note);
}
let dsh_effort = dsh_reasoning_effort(identity.reasoning_effort.as_deref()).map(str::to_string);
if let Err(reason) = base_url_is_structural(&identity.base_url) {
return MappedIdentity {
source: identity.clone(),
adapter: DshAdapter::Unsupported { reason },
dsh_reasoning_effort: None,
permission_mode,
disclosures,
};
}
let is_deepseek = matches!(identity.provider_id.as_str(), "deepseek" | "deepseek-cn");
if is_deepseek && identity.protocol == WireProtocol::ChatCompletions {
if identity.reasoning_effort.is_some() && dsh_effort.is_none() {
disclosures.push(format!(
"Codewhale reasoning tier `{}` has no DSH equivalent; DSH keeps its default (high).",
identity.reasoning_effort.as_deref().unwrap_or("")
));
}
disclosures.push(
"DSH resolves DEEPSEEK_API_KEY from its own environment or $DSH_HOME/.credentials.yaml; Codewhale does not hand over a key."
.to_string(),
);
return MappedIdentity {
source: identity.clone(),
adapter: DshAdapter::DeepseekNative,
dsh_reasoning_effort: dsh_effort,
permission_mode,
disclosures,
};
}
match identity.protocol {
WireProtocol::ChatCompletions => {}
WireProtocol::Responses => {
return MappedIdentity {
source: identity.clone(),
adapter: DshAdapter::Unsupported {
reason: "route speaks the OpenAI Responses protocol; this adapter only declares openai-completions DSH routes".to_string(),
},
dsh_reasoning_effort: None,
permission_mode,
disclosures,
};
}
WireProtocol::AnthropicMessages => {
return MappedIdentity {
source: identity.clone(),
adapter: DshAdapter::Unsupported {
reason: "route speaks the Anthropic Messages protocol; this adapter only declares openai-completions DSH routes".to_string(),
},
dsh_reasoning_effort: None,
permission_mode,
disclosures,
};
}
}
if identity.reasoning_effort.is_some() {
disclosures.push(
"Reasoning tier is not mapped for hand-declared DSH routes (per-provider wire spellings are not verified); DSH sends no effort parameter."
.to_string(),
);
}
if identity.keyless_local {
disclosures.push(
"Keyless local route: no credential reference is written; DSH talks to the endpoint without a key."
.to_string(),
);
} else if let Some(env) = identity.api_key_env.as_deref() {
disclosures.push(format!(
"DSH resolves {env} from its own environment or $DSH_HOME/.credentials.yaml; Codewhale does not hand over a key."
));
} else {
disclosures.push(
"No credential env var is known for this provider; DSH will defer to its ambient credential discovery."
.to_string(),
);
}
MappedIdentity {
source: identity.clone(),
adapter: DshAdapter::PiAiOpenAiCompatible {
route_id: route_id_for(&identity.provider_id),
},
dsh_reasoning_effort: None,
permission_mode,
disclosures,
}
}
fn yaml_str(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
pub(crate) fn render_overlay(mapped: &MappedIdentity) -> Option<String> {
let src = &mapped.source;
let mut out = String::new();
out.push_str("# DeepSeek Harness connected through Codewhale.\n");
out.push_str("# Generated by `codewhale integrations dsh connect`; do not edit by hand.\n");
out.push_str("# Identity only: no API key, token, or credential document is written here.\n");
out.push_str(&format!(
"# codewhale.provider={} codewhale.model={} codewhale.workspace={}\n",
src.provider_id, src.model, src.workspace
));
match &mapped.adapter {
DshAdapter::DeepseekNative => {
out.push_str("- id: agent-default-model\n");
out.push_str(" name: '@deepseek-ai/dsh-agent-default-model'\n");
out.push_str(" config:\n");
out.push_str(" provider: deepseek-official\n");
out.push_str(&format!(" model: {}\n", yaml_str(&src.model)));
out.push_str("- id: llm-deepseek\n");
out.push_str(" name: '@deepseek-ai/dsh-llm-deepseek'\n");
out.push_str(" config:\n");
out.push_str(&format!(" baseURL: {}\n", yaml_str(&src.base_url)));
if let Some(effort) = mapped.dsh_reasoning_effort.as_deref() {
out.push_str(&format!(" reasoningEffort: {effort}\n"));
}
out.push_str(" models:\n");
out.push_str(&format!(" - id: {}\n", yaml_str(&src.model)));
out.push_str(&format!(" name: {}\n", yaml_str(&src.model)));
}
DshAdapter::PiAiOpenAiCompatible { route_id } => {
out.push_str("- id: agent-default-model\n");
out.push_str(" name: '@deepseek-ai/dsh-agent-default-model'\n");
out.push_str(" config:\n");
out.push_str(&format!(" provider: {}\n", yaml_str(route_id)));
out.push_str(&format!(" model: {}\n", yaml_str(&src.model)));
out.push_str("- id: llm-pi-ai\n");
out.push_str(" name: '@deepseek-ai/dsh-llm-pi-ai'\n");
out.push_str(" config:\n");
out.push_str(" providers:\n");
out.push_str(&format!(" {}:\n", yaml_str(route_id)));
out.push_str(&format!(
" displayName: {}\n",
yaml_str(&format!("{} (via Codewhale)", src.provider_label))
));
if !src.keyless_local
&& let Some(env) = src.api_key_env.as_deref()
{
out.push_str(&format!(" apiKeyEnv: {}\n", yaml_str(env)));
}
out.push_str(" api: openai-completions\n");
out.push_str(&format!(" baseURL: {}\n", yaml_str(&src.base_url)));
out.push_str(" models:\n");
out.push_str(&format!(" - id: {}\n", yaml_str(&src.model)));
out.push_str(&format!(" name: {}\n", yaml_str(&src.model)));
}
DshAdapter::Unsupported { .. } => return None,
}
Some(out)
}
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex_lower(&hasher.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}