| 1 | //! Exact Codewhale route identity → DeepSeek Harness overlay mapping. |
| 2 | //! |
| 3 | //! The mapping carries *identity only*: provider id, model id, endpoint, |
| 4 | //! reasoning tier, permission posture, and the *name* of the credential |
| 5 | //! environment variable. It never carries a credential value, an OAuth |
| 6 | //! document, or a base URL that embeds userinfo/query material. |
| 7 | |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | use sha2::{Digest, Sha256}; |
| 10 | |
| 11 | /// Non-secret facts about the route Codewhale is currently configured to use. |
| 12 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 13 | pub(crate) struct CodewhaleRouteIdentity { |
| 14 | /// Exact configured provider id (e.g. `deepseek`, `ollama`, `zai`, or a |
| 15 | /// named custom table key). |
| 16 | pub(crate) provider_id: String, |
| 17 | /// Human label for the provider. |
| 18 | pub(crate) provider_label: String, |
| 19 | /// Exact model id put on the wire. |
| 20 | pub(crate) model: String, |
| 21 | /// Resolved base URL (structural; must not carry credentials). |
| 22 | pub(crate) base_url: String, |
| 23 | /// Wire protocol Codewhale speaks to this endpoint. |
| 24 | pub(crate) protocol: WireProtocol, |
| 25 | /// Canonical credential env var name for the provider, if it has one. |
| 26 | pub(crate) api_key_env: Option<String>, |
| 27 | /// True when the route is a keyless self-hosted endpoint (loopback |
| 28 | /// Ollama/LM Studio/vLLM/SGLang): no credential reference is written. |
| 29 | pub(crate) keyless_local: bool, |
| 30 | /// Codewhale reasoning tier as configured (`off|low|medium|high|xhigh|max|ultra`). |
| 31 | pub(crate) reasoning_effort: Option<String>, |
| 32 | /// Codewhale sandbox mode (`read-only|workspace-write|danger-full-access|external-sandbox`). |
| 33 | pub(crate) sandbox_mode: Option<String>, |
| 34 | /// Codewhale approval policy (`suggest|auto|never`). |
| 35 | pub(crate) approval_policy: Option<String>, |
| 36 | pub(crate) yolo: bool, |
| 37 | /// Workspace the launch is bound to. |
| 38 | pub(crate) workspace: String, |
| 39 | } |
| 40 | |
| 41 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 42 | #[serde(rename_all = "kebab-case")] |
| 43 | pub(crate) enum WireProtocol { |
| 44 | ChatCompletions, |
| 45 | Responses, |
| 46 | AnthropicMessages, |
| 47 | } |
| 48 | |
| 49 | /// DSH permission mode mirrored from the Codewhale posture. |
| 50 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 51 | #[serde(rename_all = "kebab-case")] |
| 52 | pub(crate) enum DshPermissionMode { |
| 53 | ReadOnly, |
| 54 | WorkspaceWrite, |
| 55 | DangerFullAccess, |
| 56 | } |
| 57 | |
| 58 | impl DshPermissionMode { |
| 59 | pub(crate) fn as_str(self) -> &'static str { |
| 60 | match self { |
| 61 | Self::ReadOnly => "read-only", |
| 62 | Self::WorkspaceWrite => "workspace-write", |
| 63 | Self::DangerFullAccess => "danger-full-access", |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// Which DSH adapter carries the route. |
| 69 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 70 | #[serde(tag = "kind", rename_all = "snake_case")] |
| 71 | pub(crate) enum DshAdapter { |
| 72 | /// `@deepseek-ai/dsh-llm-deepseek`, route id `deepseek-official`. |
| 73 | DeepseekNative, |
| 74 | /// `@deepseek-ai/dsh-llm-pi-ai` hand-declared route. The route names the |
| 75 | /// provider's own wire dialect (`openai-completions`, `openai-responses`, |
| 76 | /// or `anthropic-messages`); see [`pi_ai_api_for`]. |
| 77 | PiAiOpenAiCompatible { route_id: String }, |
| 78 | /// DSH cannot carry this route; nothing is written for it. |
| 79 | Unsupported { reason: String }, |
| 80 | } |
| 81 | |
| 82 | /// The identity as it will be written into the overlay, plus disclosures. |
| 83 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 84 | pub(crate) struct MappedIdentity { |
| 85 | pub(crate) source: CodewhaleRouteIdentity, |
| 86 | pub(crate) adapter: DshAdapter, |
| 87 | /// DSH `reasoningEffort` (`off|high|max`) for the native adapter; `None` |
| 88 | /// when Codewhale has no explicit tier or the adapter cannot express it. |
| 89 | pub(crate) dsh_reasoning_effort: Option<String>, |
| 90 | pub(crate) permission_mode: DshPermissionMode, |
| 91 | /// Facts the user must know that the overlay cannot enforce. |
| 92 | pub(crate) disclosures: Vec<String>, |
| 93 | } |
| 94 | |
| 95 | impl MappedIdentity { |
| 96 | pub(crate) fn mappable(&self) -> bool { |
| 97 | !matches!(self.adapter, DshAdapter::Unsupported { .. }) |
| 98 | } |
| 99 | |
| 100 | /// The `provider` value DSH sees for this route. |
| 101 | pub(crate) fn dsh_provider(&self) -> Option<&str> { |
| 102 | match &self.adapter { |
| 103 | DshAdapter::DeepseekNative => Some("deepseek-official"), |
| 104 | DshAdapter::PiAiOpenAiCompatible { route_id } => Some(route_id), |
| 105 | DshAdapter::Unsupported { .. } => None, |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Map a Codewhale reasoning tier onto DSH's `off | high | max`. |
| 111 | pub(crate) fn dsh_reasoning_effort(effort: Option<&str>) -> Option<&'static str> { |
| 112 | match effort.map(|e| e.trim().to_ascii_lowercase()).as_deref() { |
| 113 | None | Some("") => None, |
| 114 | Some("off" | "none" | "disabled" | "false") => Some("off"), |
| 115 | Some("minimal" | "low" | "medium" | "mid" | "high" | "auto") => Some("high"), |
| 116 | Some("xhigh" | "max" | "maximum" | "highest" | "ultra" | "ultracode") => Some("max"), |
| 117 | Some(_) => None, |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | /// Mirror the Codewhale posture. Full access is only granted when Codewhale |
| 122 | /// itself runs with full access *and* the caller confirmed it explicitly. |
| 123 | pub(crate) fn permission_mode_for( |
| 124 | identity: &CodewhaleRouteIdentity, |
| 125 | allow_full_access: bool, |
| 126 | ) -> (DshPermissionMode, Option<String>) { |
| 127 | let sandbox = identity |
| 128 | .sandbox_mode |
| 129 | .as_deref() |
| 130 | .map(|s| s.trim().to_ascii_lowercase()); |
| 131 | let approval = identity |
| 132 | .approval_policy |
| 133 | .as_deref() |
| 134 | .map(|s| s.trim().to_ascii_lowercase()); |
| 135 | let codewhale_full = identity.yolo |
| 136 | || matches!( |
| 137 | sandbox.as_deref(), |
| 138 | Some("danger-full-access" | "external-sandbox") |
| 139 | ); |
| 140 | if matches!(sandbox.as_deref(), Some("read-only")) { |
| 141 | return (DshPermissionMode::ReadOnly, None); |
| 142 | } |
| 143 | if codewhale_full { |
| 144 | if allow_full_access { |
| 145 | return ( |
| 146 | DshPermissionMode::DangerFullAccess, |
| 147 | Some( |
| 148 | "DSH danger-full-access mirrors Codewhale full access; DSH will not ask before file effects." |
| 149 | .to_string(), |
| 150 | ), |
| 151 | ); |
| 152 | } |
| 153 | return ( |
| 154 | DshPermissionMode::WorkspaceWrite, |
| 155 | Some( |
| 156 | "Codewhale runs with full access, but the DSH overlay stays at workspace-write; pass --allow-full-access to mirror it." |
| 157 | .to_string(), |
| 158 | ), |
| 159 | ); |
| 160 | } |
| 161 | let note = match approval.as_deref() { |
| 162 | Some("never" | "deny" | "denied") => Some( |
| 163 | "Codewhale approval policy is `never`; DSH keeps its own ask-before-effects policy at workspace-write." |
| 164 | .to_string(), |
| 165 | ), |
| 166 | _ => None, |
| 167 | }; |
| 168 | (DshPermissionMode::WorkspaceWrite, note) |
| 169 | } |
| 170 | |
| 171 | fn base_url_is_structural(url: &str) -> Result<(), String> { |
| 172 | let trimmed = url.trim(); |
| 173 | if trimmed.is_empty() { |
| 174 | return Err("empty base URL".to_string()); |
| 175 | } |
| 176 | let Some((scheme, rest)) = trimmed.split_once("://") else { |
| 177 | return Err("base URL has no scheme".to_string()); |
| 178 | }; |
| 179 | if !matches!(scheme, "http" | "https") { |
| 180 | return Err(format!("unsupported base URL scheme `{scheme}`")); |
| 181 | } |
| 182 | let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); |
| 183 | if authority.contains('@') { |
| 184 | return Err("base URL embeds userinfo; refusing to copy it".to_string()); |
| 185 | } |
| 186 | if rest.contains('?') || rest.contains('#') { |
| 187 | return Err("base URL carries a query/fragment; refusing to copy it".to_string()); |
| 188 | } |
| 189 | Ok(()) |
| 190 | } |
| 191 | |
| 192 | /// The `api:` dialect a hand-declared `dsh-llm-pi-ai` route names for a |
| 193 | /// Codewhale wire protocol — identity-preserving, never an approximation. |
| 194 | /// |
| 195 | /// Verified against the installed `@deepseek-ai/dsh@0.1.0-rc.6`: |
| 196 | /// `@deepseek-ai/dsh-llm-pi-ai`'s exported profile schema accepts exactly |
| 197 | /// `openai-completions | openai-responses | anthropic-messages` for `api:` |
| 198 | /// (`lib/index.js`: `const PROTOCOLS = { "openai-completions": …, |
| 199 | /// "openai-responses": openAIResponsesApi, "anthropic-messages": … }`), |
| 200 | /// while `@deepseek-ai/dsh-llm-deepseek` (the `deepseek-official` route) |
| 201 | /// speaks chat completions only — its single wire call posts to |
| 202 | /// `<baseURL>/chat/completions` with no protocol switch — so |
| 203 | /// Responses-dialect DeepSeek routes (e.g. `deepseek-v4-flash`) ride the |
| 204 | /// pi-ai hand-declared route instead. |
| 205 | pub(crate) fn pi_ai_api_for(protocol: WireProtocol) -> &'static str { |
| 206 | match protocol { |
| 207 | WireProtocol::ChatCompletions => "openai-completions", |
| 208 | WireProtocol::Responses => "openai-responses", |
| 209 | WireProtocol::AnthropicMessages => "anthropic-messages", |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | fn route_id_for(provider_id: &str) -> String { |
| 214 | let mut out = String::from("codewhale-"); |
| 215 | for ch in provider_id.chars() { |
| 216 | if ch.is_ascii_alphanumeric() { |
| 217 | out.push(ch.to_ascii_lowercase()); |
| 218 | } else if !out.ends_with('-') { |
| 219 | out.push('-'); |
| 220 | } |
| 221 | } |
| 222 | out.trim_end_matches('-').to_string() |
| 223 | } |
| 224 | |
| 225 | pub(crate) fn map_identity( |
| 226 | identity: &CodewhaleRouteIdentity, |
| 227 | allow_full_access: bool, |
| 228 | ) -> MappedIdentity { |
| 229 | let mut disclosures = Vec::new(); |
| 230 | let (permission_mode, note) = permission_mode_for(identity, allow_full_access); |
| 231 | if let Some(note) = note { |
| 232 | disclosures.push(note); |
| 233 | } |
| 234 | let dsh_effort = dsh_reasoning_effort(identity.reasoning_effort.as_deref()).map(str::to_string); |
| 235 | |
| 236 | if let Err(reason) = base_url_is_structural(&identity.base_url) { |
| 237 | return MappedIdentity { |
| 238 | source: identity.clone(), |
| 239 | adapter: DshAdapter::Unsupported { reason }, |
| 240 | dsh_reasoning_effort: None, |
| 241 | permission_mode, |
| 242 | disclosures, |
| 243 | }; |
| 244 | } |
| 245 | |
| 246 | let is_deepseek = matches!(identity.provider_id.as_str(), "deepseek" | "deepseek-cn"); |
| 247 | if is_deepseek && identity.protocol == WireProtocol::ChatCompletions { |
| 248 | if identity.reasoning_effort.is_some() && dsh_effort.is_none() { |
| 249 | disclosures.push(format!( |
| 250 | "Codewhale reasoning tier `{}` has no DSH equivalent; DSH keeps its default (high).", |
| 251 | identity.reasoning_effort.as_deref().unwrap_or("") |
| 252 | )); |
| 253 | } |
| 254 | disclosures.push( |
| 255 | "DSH resolves DEEPSEEK_API_KEY from its own environment or $DSH_HOME/.credentials.yaml; Codewhale does not hand over a key." |
| 256 | .to_string(), |
| 257 | ); |
| 258 | return MappedIdentity { |
| 259 | source: identity.clone(), |
| 260 | adapter: DshAdapter::DeepseekNative, |
| 261 | dsh_reasoning_effort: dsh_effort, |
| 262 | permission_mode, |
| 263 | disclosures, |
| 264 | }; |
| 265 | } |
| 266 | |
| 267 | // Every wire dialect Codewhale can speak, `dsh-llm-pi-ai` declares a |
| 268 | // hand-declared route for (see `pi_ai_api_for`), so the route is carried |
| 269 | // in its own dialect rather than refused or approximated as completions. |
| 270 | if identity.protocol != WireProtocol::ChatCompletions { |
| 271 | disclosures.push(format!( |
| 272 | "Route speaks the {} dialect; DSH carries it through the `@deepseek-ai/dsh-llm-pi-ai` hand-declared route (`api: {}`) — the wire dialect is preserved, never approximated as chat completions.", |
| 273 | dialect_label(identity.protocol), |
| 274 | pi_ai_api_for(identity.protocol) |
| 275 | )); |
| 276 | } |
| 277 | |
| 278 | if identity.reasoning_effort.is_some() { |
| 279 | disclosures.push( |
| 280 | "Reasoning tier is not mapped for hand-declared DSH routes (per-provider wire spellings are not verified); DSH sends no effort parameter." |
| 281 | .to_string(), |
| 282 | ); |
| 283 | } |
| 284 | if identity.keyless_local { |
| 285 | disclosures.push( |
| 286 | "Keyless local route: no credential reference is written; DSH talks to the endpoint without a key." |
| 287 | .to_string(), |
| 288 | ); |
| 289 | } else if let Some(env) = identity.api_key_env.as_deref() { |
| 290 | disclosures.push(format!( |
| 291 | "DSH resolves {env} from its own environment or $DSH_HOME/.credentials.yaml; Codewhale does not hand over a key." |
| 292 | )); |
| 293 | } else { |
| 294 | disclosures.push( |
| 295 | "No credential env var is known for this provider; DSH will defer to its ambient credential discovery." |
| 296 | .to_string(), |
| 297 | ); |
| 298 | } |
| 299 | MappedIdentity { |
| 300 | source: identity.clone(), |
| 301 | adapter: DshAdapter::PiAiOpenAiCompatible { |
| 302 | route_id: route_id_for(&identity.provider_id), |
| 303 | }, |
| 304 | dsh_reasoning_effort: None, |
| 305 | permission_mode, |
| 306 | disclosures, |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | fn dialect_label(protocol: WireProtocol) -> &'static str { |
| 311 | match protocol { |
| 312 | WireProtocol::ChatCompletions => "OpenAI Chat Completions", |
| 313 | WireProtocol::Responses => "OpenAI Responses", |
| 314 | WireProtocol::AnthropicMessages => "Anthropic Messages", |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | fn yaml_str(value: &str) -> String { |
| 319 | // Single-quoted YAML scalar: only `'` needs escaping. |
| 320 | format!("'{}'", value.replace('\'', "''")) |
| 321 | } |
| 322 | |
| 323 | /// Render the DSH `--patch` overlay for a mapped identity. Deterministic for |
| 324 | /// a given identity so its SHA-256 can detect drift. |
| 325 | pub(crate) fn render_overlay(mapped: &MappedIdentity) -> Option<String> { |
| 326 | let src = &mapped.source; |
| 327 | let mut out = String::new(); |
| 328 | out.push_str("# DeepSeek Harness connected through Codewhale.\n"); |
| 329 | out.push_str("# Generated by `codewhale integrations dsh connect`; do not edit by hand.\n"); |
| 330 | out.push_str("# Identity only: no API key, token, or credential document is written here.\n"); |
| 331 | out.push_str(&format!( |
| 332 | "# codewhale.provider={} codewhale.model={} codewhale.workspace={}\n", |
| 333 | src.provider_id, src.model, src.workspace |
| 334 | )); |
| 335 | match &mapped.adapter { |
| 336 | DshAdapter::DeepseekNative => { |
| 337 | out.push_str("- id: agent-default-model\n"); |
| 338 | out.push_str(" name: '@deepseek-ai/dsh-agent-default-model'\n"); |
| 339 | out.push_str(" config:\n"); |
| 340 | out.push_str(" provider: deepseek-official\n"); |
| 341 | out.push_str(&format!(" model: {}\n", yaml_str(&src.model))); |
| 342 | out.push_str("- id: llm-deepseek\n"); |
| 343 | out.push_str(" name: '@deepseek-ai/dsh-llm-deepseek'\n"); |
| 344 | out.push_str(" config:\n"); |
| 345 | out.push_str(&format!(" baseURL: {}\n", yaml_str(&src.base_url))); |
| 346 | if let Some(effort) = mapped.dsh_reasoning_effort.as_deref() { |
| 347 | out.push_str(&format!(" reasoningEffort: {effort}\n")); |
| 348 | } |
| 349 | out.push_str(" models:\n"); |
| 350 | out.push_str(&format!(" - id: {}\n", yaml_str(&src.model))); |
| 351 | out.push_str(&format!(" name: {}\n", yaml_str(&src.model))); |
| 352 | } |
| 353 | DshAdapter::PiAiOpenAiCompatible { route_id } => { |
| 354 | out.push_str("- id: agent-default-model\n"); |
| 355 | out.push_str(" name: '@deepseek-ai/dsh-agent-default-model'\n"); |
| 356 | out.push_str(" config:\n"); |
| 357 | out.push_str(&format!(" provider: {}\n", yaml_str(route_id))); |
| 358 | out.push_str(&format!(" model: {}\n", yaml_str(&src.model))); |
| 359 | out.push_str("- id: llm-pi-ai\n"); |
| 360 | out.push_str(" name: '@deepseek-ai/dsh-llm-pi-ai'\n"); |
| 361 | out.push_str(" config:\n"); |
| 362 | out.push_str(" providers:\n"); |
| 363 | out.push_str(&format!(" {}:\n", yaml_str(route_id))); |
| 364 | out.push_str(&format!( |
| 365 | " displayName: {}\n", |
| 366 | yaml_str(&format!("{} (via Codewhale)", src.provider_label)) |
| 367 | )); |
| 368 | if !src.keyless_local |
| 369 | && let Some(env) = src.api_key_env.as_deref() |
| 370 | { |
| 371 | out.push_str(&format!(" apiKeyEnv: {}\n", yaml_str(env))); |
| 372 | } |
| 373 | out.push_str(&format!(" api: {}\n", pi_ai_api_for(src.protocol))); |
| 374 | out.push_str(&format!(" baseURL: {}\n", yaml_str(&src.base_url))); |
| 375 | out.push_str(" models:\n"); |
| 376 | out.push_str(&format!(" - id: {}\n", yaml_str(&src.model))); |
| 377 | out.push_str(&format!(" name: {}\n", yaml_str(&src.model))); |
| 378 | } |
| 379 | DshAdapter::Unsupported { .. } => return None, |
| 380 | } |
| 381 | Some(out) |
| 382 | } |
| 383 | |
| 384 | pub(crate) fn sha256_hex(bytes: &[u8]) -> String { |
| 385 | let mut hasher = Sha256::new(); |
| 386 | hasher.update(bytes); |
| 387 | hex_lower(&hasher.finalize()) |
| 388 | } |
| 389 | |
| 390 | fn hex_lower(bytes: &[u8]) -> String { |
| 391 | let mut out = String::with_capacity(bytes.len() * 2); |
| 392 | for byte in bytes { |
| 393 | out.push_str(&format!("{byte:02x}")); |
| 394 | } |
| 395 | out |
| 396 | } |
| 397 |