feat(runtime-api): Phase 0 + Phase 1 — brand-neutral naming, capabilities, and dynamic tool protocol types (#3168)
Phase 0: - Rename runtime API metadata to CodeWhale Runtime API while keeping DeepSeek-prefixed env vars and headers as aliases. - Add CODEWHALE_RUNTIME_TOKEN primary with DEEPSEEK_RUNTIME_TOKEN fallback. - Accept x-codewhale-runtime-token header alongside x-deepseek-runtime-token. - Change generated token prefix from dst_ to cwrt_. - Add runtime_api_version, codewhale_version, transports, capabilities, and experimental to /v1/runtime/info while preserving old fields. - Update CLI help for --auth-token and --cors-origin. - Add CODEWHALE_CORS_ORIGINS with DEEPSEEK_CORS_ORIGINS alias. Phase 1: - Split inline pub mod runtime into crates/protocol/src/runtime/mod.rs. - Add DynamicToolSpec, DynamicToolItemStatus, DynamicToolCallParams, DynamicToolCallResult, DynamicToolCallContent, and TurnEnvironmentParams. - Accept dynamic_tools and environments on thread create, plus dynamic_tools and environment_id on turn start (no-op in Phase 0/1). Tests: - Update existing auth/health/runtime_info tests for new naming. - Add header alias tests and dynamic-tool request deserialization tests. - Add protocol crate round-trip tests for all new types. Co-authored-by: CodeWhale Agent <codewhale-agent@hmbown.local>
This commit is contained in:
@@ -1,37 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod runtime {
|
||||
use super::*;
|
||||
|
||||
pub const RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeEventEnvelope {
|
||||
#[serde(default = "default_runtime_event_envelope_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub seq: u64,
|
||||
pub event: String,
|
||||
pub kind: String,
|
||||
pub thread_id: String,
|
||||
pub turn_id: Option<String>,
|
||||
pub item_id: Option<String>,
|
||||
pub timestamp: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
fn default_runtime_event_envelope_schema_version() -> u32 {
|
||||
RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
pub mod runtime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope<T> {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION: u32 = 1;
|
||||
pub const RUNTIME_API_VERSION: &str = "1.0";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeEventEnvelope {
|
||||
#[serde(default = "default_runtime_event_envelope_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub seq: u64,
|
||||
pub event: String,
|
||||
pub kind: String,
|
||||
pub thread_id: String,
|
||||
pub turn_id: Option<String>,
|
||||
pub item_id: Option<String>,
|
||||
pub timestamp: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
fn default_runtime_event_envelope_schema_version() -> u32 {
|
||||
RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability advertisement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fixed capability map advertised by `GET /v1/runtime/info`.
|
||||
///
|
||||
/// All fields are required on serialization so clients can rely on the shape.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeCapabilities {
|
||||
pub threads: bool,
|
||||
pub turns: bool,
|
||||
pub turn_steer: bool,
|
||||
pub turn_interrupt: bool,
|
||||
pub event_replay: bool,
|
||||
pub external_tools: bool,
|
||||
pub environments: bool,
|
||||
pub worker_runtime: bool,
|
||||
}
|
||||
|
||||
/// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
|
||||
///
|
||||
/// Fields are additive and default to `false` when omitted by older servers.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RuntimeExperimentalCapabilities {
|
||||
#[serde(default)]
|
||||
pub environments: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External Tool Bridge protocol types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Specification for a dynamic external tool registered by a runtime client.
|
||||
///
|
||||
/// Example JSON from the spec:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "namespace": "tau_bench",
|
||||
/// "name": "get_reservation",
|
||||
/// "description": "Look up an airline reservation.",
|
||||
/// "input_schema": {
|
||||
/// "type": "object",
|
||||
/// "properties": {
|
||||
/// "reservation_id": { "type": "string" }
|
||||
/// },
|
||||
/// "required": ["reservation_id"],
|
||||
/// "additionalProperties": false
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DynamicToolSpec {
|
||||
/// Optional namespace that groups related tools (e.g. `"tau_bench"`).
|
||||
/// When present, the runtime may expose the tool as
|
||||
/// `<namespace>::<name>` to the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
|
||||
/// Short tool name. Combined with `namespace` it forms a unique tool id.
|
||||
pub name: String,
|
||||
|
||||
/// Human-readable description exposed to the model.
|
||||
pub description: String,
|
||||
|
||||
/// JSON Schema describing the tool's input parameters.
|
||||
pub input_schema: Value,
|
||||
|
||||
/// If true, the runtime may defer schema validation / tool loading until
|
||||
/// the model actually calls the tool.
|
||||
///
|
||||
/// Defaults to `false` so that older clients omitting this field still
|
||||
/// behave the same way.
|
||||
#[serde(default)]
|
||||
pub defer_loading: bool,
|
||||
}
|
||||
|
||||
/// Lifecycle status of a dynamic tool item shown in thread detail and event
|
||||
/// payloads.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DynamicToolItemStatus {
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Parameters identifying a dynamic tool call request emitted by the runtime.
|
||||
///
|
||||
/// This is the typed payload for `tool_call.requested` events and also the
|
||||
/// natural identifier used when the runtime looks up a pending call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DynamicToolCallParams {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub call_id: String,
|
||||
|
||||
/// Optional namespace that was registered with the tool.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
|
||||
/// Tool name that the model invoked.
|
||||
pub tool: String,
|
||||
|
||||
/// Arguments supplied by the model, validated against `input_schema`.
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Result submitted by a runtime client after executing a dynamic tool.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DynamicToolCallResult {
|
||||
/// Whether the client-side tool execution succeeded.
|
||||
pub success: bool,
|
||||
|
||||
/// Content fragments returned by the tool.
|
||||
///
|
||||
/// Defaults to an empty vector when omitted so clients can send a minimal
|
||||
/// `{ "success": false }` payload.
|
||||
#[serde(default)]
|
||||
pub content: Vec<DynamicToolCallContent>,
|
||||
}
|
||||
|
||||
/// A single content fragment inside a [`DynamicToolCallResult`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum DynamicToolCallContent {
|
||||
InputText { text: String },
|
||||
InputImage { image_url: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment targeting protocol types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Environment target selected for a turn's shell/filesystem work.
|
||||
///
|
||||
/// Example JSON:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "environment_id": "local",
|
||||
/// "cwd": "/workspace"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TurnEnvironmentParams {
|
||||
pub environment_id: String,
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_roundtrip() {
|
||||
let spec = DynamicToolSpec {
|
||||
namespace: Some("tau_bench".into()),
|
||||
name: "get_reservation".into(),
|
||||
description: "Look up an airline reservation.".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reservation_id": { "type": "string" }
|
||||
},
|
||||
"required": ["reservation_id"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: false,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&spec).unwrap();
|
||||
let deserialized: DynamicToolSpec = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(spec, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_omits_defer_loading_defaults_false() {
|
||||
let json = r#"{
|
||||
"namespace": "tau_bench",
|
||||
"name": "get_reservation",
|
||||
"description": "Look up an airline reservation.",
|
||||
"input_schema": { "type": "object" }
|
||||
}"#;
|
||||
|
||||
let spec: DynamicToolSpec = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(spec.namespace, Some("tau_bench".into()));
|
||||
assert_eq!(spec.name, "get_reservation");
|
||||
assert!(!spec.defer_loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_item_status_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&DynamicToolItemStatus::InProgress).unwrap(),
|
||||
"\"in_progress\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DynamicToolItemStatus>("\"completed\"").unwrap(),
|
||||
DynamicToolItemStatus::Completed
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DynamicToolItemStatus>("\"failed\"").unwrap(),
|
||||
DynamicToolItemStatus::Failed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_call_params_roundtrip() {
|
||||
let params = DynamicToolCallParams {
|
||||
thread_id: "thr_123".into(),
|
||||
turn_id: "turn_456".into(),
|
||||
call_id: "call_abc".into(),
|
||||
namespace: Some("tau_bench".into()),
|
||||
tool: "get_reservation".into(),
|
||||
arguments: json!({ "reservation_id": "ABC123" }),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(¶ms).unwrap();
|
||||
let deserialized: DynamicToolCallParams = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(params, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_call_content_roundtrip() {
|
||||
let content = vec![
|
||||
DynamicToolCallContent::InputText {
|
||||
text: "{\"status\":\"confirmed\"}".into(),
|
||||
},
|
||||
DynamicToolCallContent::InputImage {
|
||||
image_url: "http://example.com/receipt.png".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let value = serde_json::to_value(&content).unwrap();
|
||||
let deserialized: Vec<DynamicToolCallContent> = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(content, deserialized);
|
||||
|
||||
// Verify the exact JSON tag names expected by the spec.
|
||||
assert_eq!(
|
||||
serde_json::to_string(&DynamicToolCallContent::InputText { text: "x".into() }).unwrap(),
|
||||
r#"{"type":"input_text","text":"x"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&DynamicToolCallContent::InputImage { image_url: "y".into() })
|
||||
.unwrap(),
|
||||
r#"{"type":"input_image","image_url":"y"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_call_result_defaults_empty_content() {
|
||||
let json = r#"{ "success": false }"#;
|
||||
let result: DynamicToolCallResult = serde_json::from_str(json).unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_call_result_roundtrip_with_content() {
|
||||
let result = DynamicToolCallResult {
|
||||
success: true,
|
||||
content: vec![DynamicToolCallContent::InputText { text: "done".into() }],
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&result).unwrap();
|
||||
let deserialized: DynamicToolCallResult = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(result, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_environment_params_roundtrip() {
|
||||
let env = TurnEnvironmentParams {
|
||||
environment_id: "local".into(),
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&env).unwrap();
|
||||
let deserialized: TurnEnvironmentParams = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(env, deserialized);
|
||||
|
||||
// Verify JSON from the spec deserializes directly.
|
||||
let from_spec = r#"{
|
||||
"environment_id": "local",
|
||||
"cwd": "/workspace"
|
||||
}"#;
|
||||
let parsed: TurnEnvironmentParams = serde_json::from_str(from_spec).unwrap();
|
||||
assert_eq!(parsed.environment_id, "local");
|
||||
assert_eq!(parsed.cwd, PathBuf::from("/workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_capabilities_serializes_expected_shape() {
|
||||
let caps = RuntimeCapabilities {
|
||||
threads: true,
|
||||
turns: true,
|
||||
turn_steer: true,
|
||||
turn_interrupt: true,
|
||||
event_replay: true,
|
||||
external_tools: false,
|
||||
environments: false,
|
||||
worker_runtime: false,
|
||||
};
|
||||
let value = serde_json::to_value(&caps).unwrap();
|
||||
let obj = value.as_object().unwrap();
|
||||
assert_eq!(obj.get("threads").unwrap(), &json!(true));
|
||||
assert_eq!(obj.get("external_tools").unwrap(), &json!(false));
|
||||
assert!(obj.contains_key("worker_runtime"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_event_envelope_schema_version_default() {
|
||||
let json = r#"{
|
||||
"seq": 1,
|
||||
"event": "test",
|
||||
"kind": "test",
|
||||
"thread_id": "thr_1",
|
||||
"timestamp": "2026-06-12T00:00:00Z",
|
||||
"payload": {}
|
||||
}"#;
|
||||
let envelope: RuntimeEventEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(envelope.schema_version, RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION);
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -696,12 +696,14 @@ struct ServeArgs {
|
||||
workers: usize,
|
||||
/// Additional CORS origin to allow (repeatable). Stacks on top of the
|
||||
/// built-in defaults (localhost:3000, localhost:1420, tauri://localhost).
|
||||
/// Also reads `DEEPSEEK_CORS_ORIGINS` (comma-separated) and
|
||||
/// `[runtime_api] cors_origins` from `config.toml`. Whalescale#255.
|
||||
/// Also reads `CODEWHALE_CORS_ORIGINS` (comma-separated), then
|
||||
/// `DEEPSEEK_CORS_ORIGINS` as an alias, and `[runtime_api] cors_origins`
|
||||
/// from `config.toml`. Whalescale#255.
|
||||
#[arg(long = "cors-origin", value_name = "URL")]
|
||||
cors_origin: Vec<String>,
|
||||
/// Require this bearer token for `/v1/*` runtime API routes. Also reads
|
||||
/// `DEEPSEEK_RUNTIME_TOKEN` when omitted.
|
||||
/// `CODEWHALE_RUNTIME_TOKEN` when omitted, then `DEEPSEEK_RUNTIME_TOKEN`
|
||||
/// as an alias.
|
||||
#[arg(long = "auth-token", value_name = "TOKEN")]
|
||||
auth_token: Option<String>,
|
||||
/// Disable runtime API auth when no token is configured. Only use on a trusted loopback.
|
||||
@@ -1740,7 +1742,8 @@ fn init_plugins_dir(
|
||||
///
|
||||
/// Sources, in priority order (later sources extend earlier ones):
|
||||
/// 1. `--cors-origin URL` flags (repeatable)
|
||||
/// 2. `DEEPSEEK_CORS_ORIGINS` env var (comma-separated)
|
||||
/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated),
|
||||
/// then `DEEPSEEK_CORS_ORIGINS` as an alias
|
||||
/// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
|
||||
///
|
||||
/// The runtime API always allows the built-in dev defaults
|
||||
@@ -1761,7 +1764,9 @@ fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String>
|
||||
for o in flag_origins {
|
||||
push(o);
|
||||
}
|
||||
if let Ok(env_value) = std::env::var("DEEPSEEK_CORS_ORIGINS") {
|
||||
if let Ok(env_value) = std::env::var("CODEWHALE_CORS_ORIGINS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
|
||||
{
|
||||
for piece in env_value.split(',') {
|
||||
push(piece);
|
||||
}
|
||||
|
||||
+153
-11
@@ -1,4 +1,4 @@
|
||||
//! Runtime HTTP/SSE API for local DeepSeek automation.
|
||||
//! Runtime HTTP/SSE API for local CodeWhale automation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::convert::Infallible;
|
||||
@@ -19,7 +19,10 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::Utc;
|
||||
use codewhale_protocol::runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope};
|
||||
use codewhale_protocol::runtime::{
|
||||
RUNTIME_API_VERSION, RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeCapabilities,
|
||||
RuntimeEventEnvelope, RuntimeExperimentalCapabilities,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
@@ -77,11 +80,13 @@ pub struct RuntimeApiOptions {
|
||||
/// Additional CORS origins to allow on top of the built-in defaults
|
||||
/// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,
|
||||
/// `tauri://localhost`). Populated by `--cors-origin` (repeatable),
|
||||
/// `DEEPSEEK_CORS_ORIGINS` (comma-separated), and `[runtime_api]
|
||||
/// cors_origins` in `config.toml`. Whalescale#255 / #561.
|
||||
/// `CODEWHALE_CORS_ORIGINS` (comma-separated, `DEEPSEEK_CORS_ORIGINS`
|
||||
/// as alias), and `[runtime_api] cors_origins` in `config.toml`.
|
||||
/// Whalescale#255 / #561.
|
||||
pub cors_origins: Vec<String>,
|
||||
/// Optional bearer token required for `/v1/*` routes. If omitted here,
|
||||
/// `run_http_server` also checks `DEEPSEEK_RUNTIME_TOKEN`.
|
||||
/// `run_http_server` checks `CODEWHALE_RUNTIME_TOKEN`, then
|
||||
/// `DEEPSEEK_RUNTIME_TOKEN` as an alias.
|
||||
pub auth_token: Option<String>,
|
||||
/// Allow `/v1/*` routes without auth when no token is configured.
|
||||
pub insecure_no_auth: bool,
|
||||
@@ -144,7 +149,7 @@ fn first_nonblank_token(token: Option<String>) -> Option<String> {
|
||||
|
||||
fn generate_runtime_token() -> String {
|
||||
format!(
|
||||
"dst_{}{}",
|
||||
"cwrt_{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
)
|
||||
@@ -359,12 +364,32 @@ struct SubmitUserInputResponse {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RuntimeInfoResponse {
|
||||
service: &'static str,
|
||||
runtime_api_version: &'static str,
|
||||
codewhale_version: &'static str,
|
||||
bind_host: String,
|
||||
port: u16,
|
||||
auth_required: bool,
|
||||
transports: Vec<&'static str>,
|
||||
capabilities: RuntimeCapabilities,
|
||||
experimental: RuntimeExperimentalCapabilities,
|
||||
// Backward-compatible alias kept for existing clients.
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
fn default_runtime_capabilities() -> RuntimeCapabilities {
|
||||
RuntimeCapabilities {
|
||||
threads: true,
|
||||
turns: true,
|
||||
turn_steer: true,
|
||||
turn_interrupt: true,
|
||||
event_replay: true,
|
||||
external_tools: false,
|
||||
environments: false,
|
||||
worker_runtime: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct McpServerEntry {
|
||||
name: String,
|
||||
@@ -456,9 +481,12 @@ pub async fn run_http_server(
|
||||
.map(|h| h.join(".deepseek").join("sessions"))
|
||||
.unwrap_or_else(|| PathBuf::from(".deepseek").join("sessions"))
|
||||
});
|
||||
let runtime_token_env = std::env::var("CODEWHALE_RUNTIME_TOKEN")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("DEEPSEEK_RUNTIME_TOKEN").ok());
|
||||
let resolved_auth = resolve_runtime_auth(
|
||||
options.auth_token.clone(),
|
||||
std::env::var("DEEPSEEK_RUNTIME_TOKEN").ok(),
|
||||
runtime_token_env,
|
||||
options.insecure_no_auth,
|
||||
);
|
||||
let runtime_token = resolved_auth.token.clone();
|
||||
@@ -500,7 +528,7 @@ pub async fn run_http_server(
|
||||
if let Some(token) = runtime_token.as_deref() {
|
||||
println!("Runtime API auth: generated bearer token for this process.");
|
||||
println!(" Authorization: Bearer {token}");
|
||||
println!(" Set DEEPSEEK_RUNTIME_TOKEN or pass --auth-token for a stable token.");
|
||||
println!(" Set CODEWHALE_RUNTIME_TOKEN (or DEEPSEEK_RUNTIME_TOKEN as an alias) or pass --auth-token for a stable token.");
|
||||
}
|
||||
} else if auth_enabled {
|
||||
println!("Runtime API auth: bearer token required for /v1/* routes.");
|
||||
@@ -638,6 +666,11 @@ fn request_has_runtime_token(req: &Request, expected: &str) -> bool {
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|raw| raw.strip_prefix("Bearer "))
|
||||
.is_some_and(|token| token == expected)
|
||||
|| req
|
||||
.headers()
|
||||
.get("x-codewhale-runtime-token")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|token| token == expected)
|
||||
|| req
|
||||
.headers()
|
||||
.get("x-deepseek-runtime-token")
|
||||
@@ -784,7 +817,7 @@ fn detect_lan_ip() -> Option<String> {
|
||||
async fn health() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok",
|
||||
service: "deepseek-runtime-api",
|
||||
service: "codewhale-runtime-api",
|
||||
mode: "local",
|
||||
})
|
||||
}
|
||||
@@ -853,6 +886,7 @@ async fn resume_session_thread(
|
||||
archived: false,
|
||||
system_prompt: session.system_prompt.clone(),
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Failed to create thread: {e}")))?;
|
||||
@@ -1396,11 +1430,18 @@ async fn submit_user_input(
|
||||
}
|
||||
|
||||
async fn runtime_info(State(state): State<RuntimeApiState>) -> Json<RuntimeInfoResponse> {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
Json(RuntimeInfoResponse {
|
||||
service: "codewhale-runtime-api",
|
||||
runtime_api_version: RUNTIME_API_VERSION,
|
||||
codewhale_version: version,
|
||||
bind_host: state.bind_host.clone(),
|
||||
port: state.bind_port,
|
||||
auth_required: state.auth_required,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
transports: vec!["http", "sse"],
|
||||
capabilities: default_runtime_capabilities(),
|
||||
experimental: RuntimeExperimentalCapabilities::default(),
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1800,6 +1841,7 @@ async fn stream_turn(
|
||||
archived: true,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Failed to create stream thread: {e}")))?;
|
||||
@@ -1816,6 +1858,7 @@ async fn stream_turn(
|
||||
allow_shell: Some(allow_shell),
|
||||
trust_mode: Some(trust_mode),
|
||||
auto_approve: Some(auto_approve),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2587,7 +2630,7 @@ mod tests {
|
||||
let auth = resolve_runtime_auth(None, None, false);
|
||||
assert!(auth.generated);
|
||||
let token = auth.token.expect("generated token");
|
||||
assert!(token.starts_with("dst_"));
|
||||
assert!(token.starts_with("cwrt_"));
|
||||
assert!(token.len() > 32);
|
||||
}
|
||||
|
||||
@@ -2891,6 +2934,7 @@ mod tests {
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(health["status"], "ok");
|
||||
assert_eq!(health["service"], "codewhale-runtime-api");
|
||||
|
||||
let created: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/tasks"))
|
||||
@@ -2976,6 +3020,22 @@ mod tests {
|
||||
.error_for_status()?;
|
||||
assert_eq!(query_token.status(), StatusCode::OK);
|
||||
|
||||
let codewhale_header = client
|
||||
.get(format!("http://{addr}/v1/threads/summary"))
|
||||
.header("x-codewhale-runtime-token", &token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
assert_eq!(codewhale_header.status(), StatusCode::OK);
|
||||
|
||||
let deepseek_header = client
|
||||
.get(format!("http://{addr}/v1/threads/summary"))
|
||||
.header("x-deepseek-runtime-token", &token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
assert_eq!(deepseek_header.status(), StatusCode::OK);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -4753,9 +4813,91 @@ mod tests {
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(info["service"], "codewhale-runtime-api");
|
||||
assert_eq!(info["runtime_api_version"], "1.0");
|
||||
assert_eq!(info["codewhale_version"], info["version"]);
|
||||
assert_eq!(info["bind_host"], "127.0.0.1");
|
||||
assert_eq!(info["auth_required"], false);
|
||||
assert!(info["version"].is_string());
|
||||
assert_eq!(info["transports"], json!(["http", "sse"]));
|
||||
assert_eq!(info["capabilities"]["threads"], true);
|
||||
assert_eq!(info["capabilities"]["external_tools"], false);
|
||||
assert!(info["experimental"].is_object());
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_thread_accepts_dynamic_tools_and_environments() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let created: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({
|
||||
"model": "test-model",
|
||||
"dynamic_tools": [
|
||||
{
|
||||
"namespace": "tau_bench",
|
||||
"name": "get_reservation",
|
||||
"description": "Look up a reservation.",
|
||||
"input_schema": { "type": "object" }
|
||||
}
|
||||
],
|
||||
"environments": [
|
||||
{ "environment_id": "local", "cwd": "/workspace" }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert!(created["id"].is_string());
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let created: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({ "model": "test-model" }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let thread_id = created["id"].as_str().context("missing thread id")?;
|
||||
|
||||
let started: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
|
||||
.json(&json!({
|
||||
"prompt": "hello",
|
||||
"dynamic_tools": [
|
||||
{
|
||||
"name": "simple_tool",
|
||||
"description": "A simple tool.",
|
||||
"input_schema": { "type": "object" }
|
||||
}
|
||||
],
|
||||
"environment_id": "local"
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(started["turn"]["thread_id"], thread_id);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
|
||||
@@ -39,6 +39,7 @@ use crate::tools::plan::new_shared_plan_state;
|
||||
use crate::tools::subagent::SubAgentStatus;
|
||||
use crate::tools::todo::new_shared_todo_list;
|
||||
use crate::tui::app::AppMode;
|
||||
use codewhale_protocol::runtime::{DynamicToolSpec, TurnEnvironmentParams};
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
const MAX_ACTIVE_THREADS_DEFAULT: usize = 8;
|
||||
@@ -614,7 +615,7 @@ pub enum ThreadListFilter {
|
||||
ArchivedOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CreateThreadRequest {
|
||||
pub model: Option<String>,
|
||||
pub workspace: Option<PathBuf>,
|
||||
@@ -628,6 +629,10 @@ pub struct CreateThreadRequest {
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub task_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dynamic_tools: Vec<DynamicToolSpec>,
|
||||
#[serde(default)]
|
||||
pub environments: Vec<TurnEnvironmentParams>,
|
||||
}
|
||||
|
||||
/// Mutable fields accepted by `PATCH /v1/threads/{id}`.
|
||||
@@ -648,7 +653,7 @@ pub struct UpdateThreadRequest {
|
||||
pub workspace: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct StartTurnRequest {
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
@@ -658,6 +663,10 @@ pub struct StartTurnRequest {
|
||||
pub allow_shell: Option<bool>,
|
||||
pub trust_mode: Option<bool>,
|
||||
pub auto_approve: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub dynamic_tools: Vec<DynamicToolSpec>,
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -3776,6 +3785,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -3836,6 +3846,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -3882,6 +3893,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -3906,6 +3918,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -3972,6 +3985,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4005,6 +4019,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4067,6 +4082,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4084,6 +4100,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4110,6 +4127,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4127,6 +4145,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4153,6 +4172,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4186,6 +4206,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4236,6 +4257,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4296,6 +4318,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4313,6 +4336,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4360,6 +4384,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4435,6 +4460,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4475,6 +4501,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4527,6 +4554,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4542,6 +4570,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4613,6 +4642,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4628,6 +4658,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4708,6 +4739,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4723,6 +4755,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4788,6 +4821,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let mut harness = install_mock_engine(&manager, &thread.id).await;
|
||||
@@ -4803,6 +4837,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4899,6 +4934,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
assert!(!manager.store.load_thread(&thread.id)?.auto_approve);
|
||||
@@ -4915,6 +4951,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -4983,6 +5020,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4998,6 +5036,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: Some(true),
|
||||
auto_approve: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -5068,6 +5107,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -5125,6 +5165,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -5177,6 +5218,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -5275,6 +5317,7 @@ mod tests {
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -5691,6 +5734,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
seed_turns_with_user_messages(&manager, &thread.id, &["first", "second", "third"])?;
|
||||
@@ -5727,6 +5771,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
seed_turns_with_user_messages(&manager, &thread.id, &["a", "b", "c", "d"])?;
|
||||
@@ -5756,6 +5801,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
seed_turns_with_user_messages(&manager, &thread.id, &["only"])?;
|
||||
@@ -5782,6 +5828,7 @@ mod tests {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_ids = seed_turns_with_user_messages(&manager, &thread.id, &["x", "y", "z"])?;
|
||||
|
||||
@@ -441,6 +441,7 @@ impl TaskExecutor for EngineTaskExecutor {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: Some(task.id.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -466,6 +467,7 @@ impl TaskExecutor for EngineTaskExecutor {
|
||||
allow_shell: Some(task.allow_shell),
|
||||
trust_mode: Some(task.trust_mode),
|
||||
auto_approve: Some(task.auto_approve),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user