feat(tui): telemetry feature and error counters
Eleven counters and six error counters, every one bumped at a call site and
every one a count of a variant discriminant rather than of a rendered string.
The siting is the substance:
- `turns` is counted at the *caller* of `execute_turn_end_observer_hook`,
not inside it. That function's first statement returns early for anyone
with no TurnEnd hooks, and the natural future optimization hoists the
check up to the caller — which would silently zero the counter for
exactly the users who do not use hooks.
- `tool_calls` and `memory_search` sit at the surface-agnostic dispatch
choke point, so they fire for exec and the CLI and not only the TUI.
- `fleet_dispatch` sits at the single creation funnel, after validation:
`create_run` and `create_queued_run` both land there, so counting at
either would double-count a plain `fleet run`, and a rejected spec is
not a dispatch.
- `workflow_run` keys off the parsed `WorkflowAction` discriminant, never
off `input["action"]`. The JSON Schema published to the model is a
declaration, not a guard: the real parse also accepts spawn, wait, list,
inspect, stop, and abort, and its reject arm embeds the model's string
verbatim.
What is deliberately *not* recorded is the other half. The approval counters
are counts with no matched rule, no reason, no command, no argv — auto-allow
patterns are user-authored command strings. The MCP counter is a count of
connected servers with no name, command, URL, or error; server names routinely
name internal infrastructure. The tool error counters take the match arm, not
the error: `ToolError::PathEscape`'s `Display` *is* an absolute path.
Provider HTTP status is captured from the response at all three request sites,
before any `LlmError` is built, because every variant of that error carries the
raw provider body verbatim and a 400 from a content filter routinely echoes the
prompt. The same call records the provider as a `ProviderKind` by value. That
is the single most likely leak in this feature: the persistence identity, the
exec stream meta, and the planned route's effective label all return the
customer's own `[providers.<name>]` table key when the route is custom, and
`/status` already prints it. `ProviderKind::Custom` yields the literal
`"custom"`, and a test asserts every recorded provider is a member of the
closed set.
Gate: cargo test -p codewhale-tui --bin codewhale-tui (9797 passed)
This commit is contained in:
+121
-75
@@ -2563,11 +2563,40 @@ impl DeepSeekClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that a request was routed to `provider` and came back with `status`.
|
||||
///
|
||||
/// Called at every provider response site, **before** the error is built: an
|
||||
/// `LlmError` carries the raw provider body verbatim, so the status class has
|
||||
/// to be taken from the response itself.
|
||||
///
|
||||
/// The provider is recorded as a `ProviderKind` by value. Every accessor that
|
||||
/// looks like the natural seam here — the persistence identity, the stream
|
||||
/// meta's `provider_id`, the planned route's effective label — returns the
|
||||
/// customer's own `[providers.<name>]` table key when the route is custom.
|
||||
/// `ProviderKind::Custom` yields the literal `"custom"` and nothing else, and
|
||||
/// no model id is sent for any provider.
|
||||
pub(crate) fn record_provider_response(provider: crate::config::ApiProvider, status: u16) {
|
||||
let counters = codewhale_telemetry::session_counters();
|
||||
if let Some(kind) = provider.kind() {
|
||||
counters.record_provider(kind);
|
||||
}
|
||||
if let Some(counter) = codewhale_telemetry::counters::http_status_counter(status) {
|
||||
counters.bump_error(counter);
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the structured `LlmError` into both a categorical label
|
||||
/// (for structured logs / metrics) and a short human reason string
|
||||
/// (for the retry banner). Returning both from one match avoids the
|
||||
/// double-classification we had before.
|
||||
fn retry_reason_label_and_human(err: &LlmError) -> (&'static str, String) {
|
||||
// The variant, never the payload. Every `LlmError` variant carries the raw
|
||||
// provider HTTP body verbatim, and a 400 from a content filter routinely
|
||||
// echoes the prompt.
|
||||
if matches!(err, LlmError::NetworkError(_) | LlmError::Timeout(_)) {
|
||||
codewhale_telemetry::session_counters()
|
||||
.bump_error(codewhale_telemetry::ErrorCounter::NetworkError);
|
||||
}
|
||||
match err {
|
||||
LlmError::RateLimited { retry_after, .. } => {
|
||||
let human = if let Some(after) = retry_after {
|
||||
@@ -3081,21 +3110,17 @@ pub(super) fn apply_reasoning_effort(
|
||||
// (qwen-max, deepseek-chat, gpt-4o, claude, etc.) accepts the same
|
||||
// reasoning dialect (#4188 review: verify against actual behavior).
|
||||
ApiProvider::Telecomjs => {}
|
||||
// Model Studio (DashScope): the OpenAI-compatible Chat Completions
|
||||
// endpoints (Token Plan `compatible-mode/v1`, Coding Plan
|
||||
// `coding-intl.dashscope.aliyuncs.com/v1`) accept the non-standard
|
||||
// top-level `enable_thinking` switch for their hybrid-thinking
|
||||
// catalog (qwen3.x, deepseek-v4, glm-5.x) and stream reasoning as
|
||||
// `delta.reasoning_content`. The Anthropic-dialect variants never
|
||||
// reach this Chat path — the Messages adapter shapes their
|
||||
// `thinking` block instead. Source:
|
||||
// <https://www.alibabacloud.com/help/en/model-studio/deep-thinking>
|
||||
// Model Studio (DashScope): its top-level controls are route- AND
|
||||
// model-specific, so the provider enum alone cannot decide them —
|
||||
// a custom `base_url` on the same identity is an arbitrary
|
||||
// gateway. `apply_modelstudio_route_reasoning_controls` in
|
||||
// client::chat is the sole writer; it strips these fields for all
|
||||
// four variants and re-adds them only on a verified Alibaba host.
|
||||
// Source: <https://www.alibabacloud.com/help/en/model-studio/deep-thinking>
|
||||
ApiProvider::ModelstudioTokenPlan
|
||||
| ApiProvider::ModelstudioTokenPlanAnthropic
|
||||
| ApiProvider::ModelstudioCodingPlan
|
||||
| ApiProvider::ModelstudioCodingPlanAnthropic => {
|
||||
body["enable_thinking"] = json!(false);
|
||||
}
|
||||
| ApiProvider::ModelstudioCodingPlanAnthropic => {}
|
||||
ApiProvider::OpenaiCodex => {
|
||||
// OpenAI Codex uses Responses API — thinking handled differently
|
||||
}
|
||||
@@ -3184,15 +3209,12 @@ pub(super) fn apply_reasoning_effort(
|
||||
// TelecomJS: see comment in the "off" branch above — the gateway's
|
||||
// Chat Completions API does not support reasoning_effort or thinking.
|
||||
ApiProvider::Telecomjs => {}
|
||||
// Model Studio: DashScope's hybrid-thinking switch (see the "off"
|
||||
// branch). The OpenAI dialect has no effort ladder — any non-off
|
||||
// level simply turns thinking on.
|
||||
// Model Studio: see the "off" branch — the route- and model-aware
|
||||
// shaper in client::chat is the sole writer of these fields.
|
||||
ApiProvider::ModelstudioTokenPlan
|
||||
| ApiProvider::ModelstudioTokenPlanAnthropic
|
||||
| ApiProvider::ModelstudioCodingPlan
|
||||
| ApiProvider::ModelstudioCodingPlanAnthropic => {
|
||||
body["enable_thinking"] = json!(true);
|
||||
}
|
||||
| ApiProvider::ModelstudioCodingPlanAnthropic => {}
|
||||
// OpenRouter/Novita/Together: pass through the actual user-chosen value.
|
||||
// OpenRouter's unified scale is none/minimal/low/medium/high/xhigh;
|
||||
// DeepSeek models hosted there accept those directly.
|
||||
@@ -3288,15 +3310,12 @@ pub(super) fn apply_reasoning_effort(
|
||||
// TelecomJS: see comment in the "off" branch above — the gateway's
|
||||
// Chat Completions API does not support reasoning_effort or thinking.
|
||||
ApiProvider::Telecomjs => {}
|
||||
// Model Studio: DashScope's hybrid-thinking switch (see the "off"
|
||||
// branch). The OpenAI dialect has no effort ladder — any non-off
|
||||
// level simply turns thinking on.
|
||||
// Model Studio: see the "off" branch — the route- and model-aware
|
||||
// shaper in client::chat is the sole writer of these fields.
|
||||
ApiProvider::ModelstudioTokenPlan
|
||||
| ApiProvider::ModelstudioTokenPlanAnthropic
|
||||
| ApiProvider::ModelstudioCodingPlan
|
||||
| ApiProvider::ModelstudioCodingPlanAnthropic => {
|
||||
body["enable_thinking"] = json!(true);
|
||||
}
|
||||
| ApiProvider::ModelstudioCodingPlanAnthropic => {}
|
||||
ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Together => {
|
||||
body["reasoning_effort"] = json!("xhigh");
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
@@ -4290,61 +4309,98 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn assert_modelstudio_request_truth(streaming: bool) {
|
||||
// Token Plan and Coding Plan share the DashScope `enable_thinking`
|
||||
// switch on their OpenAI-compatible Chat Completions endpoints.
|
||||
// Token Plan and Coding Plan share DashScope's reasoning controls on
|
||||
// their OpenAI-compatible Chat Completions endpoints — but the fields
|
||||
// are model-specific, not provider-wide.
|
||||
for base_url in [
|
||||
crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
|
||||
crate::config::DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
|
||||
] {
|
||||
let (off_path, off) = capture_modelstudio_chat_request(
|
||||
base_url,
|
||||
crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
|
||||
Some("off"),
|
||||
streaming,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(off_path, "/v1/chat/completions");
|
||||
assert_eq!(off["enable_thinking"], json!(false), "{base_url}: {off}");
|
||||
assert!(off.get("thinking").is_none(), "{base_url}: {off}");
|
||||
assert!(off.get("reasoning_effort").is_none(), "{base_url}: {off}");
|
||||
|
||||
for effort in ["high", "max"] {
|
||||
let (_, body) = capture_modelstudio_chat_request(
|
||||
// The default model, qwen3.8-max, is thinking-only: the bundled
|
||||
// catalog records it as `thinking: always_on`, and
|
||||
// qwen3.8-max-preview has effort/budget options with no toggle.
|
||||
// Neither accepts an enable/disable switch, so CodeWhale must not
|
||||
// send one — not even `false` for an explicit `off`. This assertion
|
||||
// used to pin the opposite; PR #5233 caught it.
|
||||
for effort in [None, Some("off"), Some("high"), Some("max")] {
|
||||
let (path, body) = capture_modelstudio_chat_request(
|
||||
base_url,
|
||||
crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
|
||||
Some(effort),
|
||||
effort,
|
||||
streaming,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
body["enable_thinking"],
|
||||
json!(true),
|
||||
"{base_url} {effort}: {body}"
|
||||
assert_eq!(path, "/v1/chat/completions");
|
||||
assert!(
|
||||
body.get("enable_thinking").is_none(),
|
||||
"{base_url} {effort:?}: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.get("thinking").is_none(),
|
||||
"{base_url} {effort}: {body}"
|
||||
"{base_url} {effort:?}: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.get("reasoning_effort").is_none(),
|
||||
"{base_url} {effort}: {body}"
|
||||
"{base_url} {effort:?}: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// Unset effort sends no switch — the server default (thinking-ON
|
||||
// for the qwen3.x families) is left alone.
|
||||
let (_, unset) = capture_modelstudio_chat_request(
|
||||
// A hybrid model does get the documented switch, plus
|
||||
// `preserve_thinking` so the next turn keeps its trace.
|
||||
for (effort, enabled) in [(None, true), (Some("high"), true), (Some("off"), false)] {
|
||||
let (_, body) =
|
||||
capture_modelstudio_chat_request(base_url, "qwen3.7-plus", effort, streaming)
|
||||
.await;
|
||||
assert_eq!(
|
||||
body["enable_thinking"],
|
||||
json!(enabled),
|
||||
"{base_url} {effort:?}: {body}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["preserve_thinking"],
|
||||
json!(enabled),
|
||||
"{base_url} {effort:?}: {body}"
|
||||
);
|
||||
// The hybrid Qwen families have no effort ladder on the wire.
|
||||
assert!(
|
||||
body.get("reasoning_effort").is_none(),
|
||||
"{base_url} {effort:?}: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// DeepSeek-V4 is one of the two families with a documented effort
|
||||
// ladder (`high` / `max`).
|
||||
let (_, deepseek) = capture_modelstudio_chat_request(
|
||||
base_url,
|
||||
crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
|
||||
None,
|
||||
"deepseek-v4-pro",
|
||||
Some("xhigh"),
|
||||
streaming,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
unset.get("enable_thinking").is_none(),
|
||||
"{base_url}: {unset}"
|
||||
assert_eq!(
|
||||
deepseek["enable_thinking"],
|
||||
json!(true),
|
||||
"{base_url}: {deepseek}"
|
||||
);
|
||||
assert_eq!(
|
||||
deepseek["reasoning_effort"],
|
||||
json!("max"),
|
||||
"{base_url}: {deepseek}"
|
||||
);
|
||||
}
|
||||
|
||||
// Fail closed: the same provider identity pointed at a custom gateway
|
||||
// must not be handed Alibaba's dialect.
|
||||
let (_, proxied) = capture_modelstudio_chat_request(
|
||||
"https://proxy.example/v1",
|
||||
"qwen3.7-plus",
|
||||
Some("high"),
|
||||
streaming,
|
||||
)
|
||||
.await;
|
||||
assert!(proxied.get("enable_thinking").is_none(), "{proxied}");
|
||||
assert!(proxied.get("preserve_thinking").is_none(), "{proxied}");
|
||||
assert!(proxied.get("reasoning_effort").is_none(), "{proxied}");
|
||||
}
|
||||
|
||||
async fn assert_zai_request_truth(streaming: bool) {
|
||||
@@ -7404,34 +7460,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_modelstudio_speaks_dashscope_enable_thinking() {
|
||||
// All four plan/dialect variants share the DashScope hybrid-thinking
|
||||
// switch on the OpenAI-compatible Chat Completions path.
|
||||
fn reasoning_effort_modelstudio_writes_nothing_without_a_verified_route() {
|
||||
// The provider enum cannot decide DashScope's controls: `enable_thinking`
|
||||
// is wrong for the thinking-only models, `reasoning_effort` is only
|
||||
// valid for DeepSeek-V4/GLM, and a custom `base_url` on any of these
|
||||
// identities is an arbitrary gateway. All four variants must therefore
|
||||
// leave the body untouched here — the route shaper in client::chat is
|
||||
// the sole writer.
|
||||
for provider in [
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
ApiProvider::ModelstudioTokenPlanAnthropic,
|
||||
ApiProvider::ModelstudioCodingPlan,
|
||||
ApiProvider::ModelstudioCodingPlanAnthropic,
|
||||
] {
|
||||
let mut off = json!({});
|
||||
apply_reasoning_effort(&mut off, Some("off"), provider);
|
||||
assert_eq!(off, json!({ "enable_thinking": false }), "{provider:?}");
|
||||
|
||||
for effort in ["low", "high", "max"] {
|
||||
for effort in [None, Some("off"), Some("low"), Some("high"), Some("max")] {
|
||||
let mut body = json!({});
|
||||
apply_reasoning_effort(&mut body, Some(effort), provider);
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({ "enable_thinking": true }),
|
||||
"{provider:?} {effort}"
|
||||
);
|
||||
apply_reasoning_effort(&mut body, effort, provider);
|
||||
assert_eq!(body, json!({}), "{provider:?} {effort:?}");
|
||||
}
|
||||
|
||||
// Unset effort stays silent: the qwen3.x families default to
|
||||
// thinking-ON server-side and the client must not flip that.
|
||||
let mut unset = json!({});
|
||||
apply_reasoning_effort(&mut unset, None, provider);
|
||||
assert_eq!(unset, json!({}), "{provider:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -291,6 +291,170 @@ fn apply_minimax_route_reasoning_controls(
|
||||
}
|
||||
}
|
||||
|
||||
/// Model Studio's OpenAI-compatible API uses its own top-level reasoning
|
||||
/// controls. Keep them on verified Alibaba Chat Completions routes: a custom
|
||||
/// `base_url` points the same provider identity at an arbitrary gateway, and
|
||||
/// that gateway must not be handed Alibaba's dialect.
|
||||
///
|
||||
/// This is the *sole* writer of Model Studio reasoning fields —
|
||||
/// `apply_reasoning_effort` deliberately writes nothing for the `Modelstudio*`
|
||||
/// identities — so the strip below runs for all four variants, including the
|
||||
/// two Anthropic-dialect ones. Those normally reach the Messages adapter
|
||||
/// instead, but `wire = "openai"` can route them here, and an unmatched
|
||||
/// `enable_thinking` left in the body would then go out unguarded.
|
||||
fn apply_modelstudio_route_reasoning_controls(
|
||||
body: &mut Value,
|
||||
provider: ApiProvider,
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
effort: Option<&str>,
|
||||
) {
|
||||
if !matches!(
|
||||
provider,
|
||||
ApiProvider::ModelstudioTokenPlan
|
||||
| ApiProvider::ModelstudioTokenPlanAnthropic
|
||||
| ApiProvider::ModelstudioCodingPlan
|
||||
| ApiProvider::ModelstudioCodingPlanAnthropic
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
object.remove("thinking");
|
||||
object.remove("enable_thinking");
|
||||
object.remove("preserve_thinking");
|
||||
object.remove("reasoning_effort");
|
||||
}
|
||||
if !is_exact_modelstudio_chat_route(provider, base_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
let thinking_only = modelstudio_model_is_thinking_only(model);
|
||||
if !thinking_only && !modelstudio_model_is_hybrid(model) {
|
||||
return;
|
||||
}
|
||||
|
||||
let thinking_enabled = !modelstudio_effort_disables_thinking(effort);
|
||||
// Thinking-only models emit `reasoning_content` but reject an
|
||||
// enable/disable control. Hybrid models use `enable_thinking`.
|
||||
if !thinking_only {
|
||||
body["enable_thinking"] = json!(thinking_enabled);
|
||||
}
|
||||
if modelstudio_model_supports_preserve_thinking(model) {
|
||||
// Model Studio otherwise drops assistant `reasoning_content` from the
|
||||
// next turn's context. This applies even when the provider default
|
||||
// leaves thinking enabled and no explicit UI effort was selected.
|
||||
body["preserve_thinking"] = json!(thinking_only || thinking_enabled);
|
||||
}
|
||||
if !thinking_only
|
||||
&& thinking_enabled
|
||||
&& let Some(effort) = effort.and_then(modelstudio_reasoning_effort_for_model)
|
||||
&& modelstudio_model_supports_reasoning_effort(model)
|
||||
{
|
||||
body["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail-closed host guard: only Alibaba's own OpenAI-compatible Chat
|
||||
/// Completions URL shapes count. Anything else (a proxy, a self-hosted
|
||||
/// gateway, a typo) gets the Model Studio fields stripped and nothing added.
|
||||
fn is_exact_modelstudio_chat_route(provider: ApiProvider, base_url: &str) -> bool {
|
||||
let trimmed = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
|
||||
let Some((host, path)) = trimmed
|
||||
.strip_prefix("https://")
|
||||
.and_then(|rest| rest.split_once('/'))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Includes Token Plan's default and workspace-scoped
|
||||
// `{workspace}.<region>.maas.aliyuncs.com/compatible-mode/v1` hosts.
|
||||
let token_plan_chat = host.ends_with(".maas.aliyuncs.com") && path == "compatible-mode/v1";
|
||||
let coding_plan_chat = host == "coding-intl.dashscope.aliyuncs.com" && path == "v1";
|
||||
|
||||
match provider {
|
||||
// The primary Model Studio provider selects Coding Plan through
|
||||
// `mode = "coding-plan"`, which resolves this base URL without
|
||||
// changing the provider enum. Legacy Coding Plan identities remain
|
||||
// supported as well, so recognize either official Chat route for the
|
||||
// complete Model Studio OpenAI family. The `*Anthropic` identities
|
||||
// speak the Messages dialect and are never verified here.
|
||||
ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioCodingPlan => {
|
||||
token_plan_chat || coding_plan_chat
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_exact_modelstudio_thinking_only_route(
|
||||
provider: ApiProvider,
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
) -> bool {
|
||||
is_exact_modelstudio_chat_route(provider, base_url) && modelstudio_model_is_thinking_only(model)
|
||||
}
|
||||
|
||||
fn modelstudio_effort_disables_thinking(effort: Option<&str>) -> bool {
|
||||
effort.is_some_and(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"off" | "disabled" | "none" | "false"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Models with no enable/disable control at all. `models_dev.bundled.json`
|
||||
/// lists `qwen3.8-max` as `thinking: always_on` and gives `qwen3.8-max-preview`
|
||||
/// effort/budget options with no `toggle`, so sending `enable_thinking` to
|
||||
/// either is at best ignored and at worst a 400.
|
||||
fn modelstudio_model_is_thinking_only(model: &str) -> bool {
|
||||
let model = model.trim().to_ascii_lowercase();
|
||||
matches!(model.as_str(), "qwen3.8-max" | "qwen3.8-max-preview")
|
||||
// Kimi K2.7 Code on Model Studio is reported always-thinking and
|
||||
// supporting preserve_thinking. Keep it separate from hybrid Kimi
|
||||
// variants so we do not send the unsupported enable_thinking switch.
|
||||
|| model.starts_with("kimi-k2.7-code")
|
||||
}
|
||||
|
||||
fn modelstudio_model_is_hybrid(model: &str) -> bool {
|
||||
let model = model.trim().to_ascii_lowercase();
|
||||
model.starts_with("qwen3.7-")
|
||||
|| model.starts_with("qwen3.6-")
|
||||
|| model.starts_with("qwen3.5-")
|
||||
|| model.starts_with("qwen3-")
|
||||
|| model.starts_with("deepseek-v4")
|
||||
|| model.starts_with("deepseek-v3.2")
|
||||
|| model.starts_with("deepseek-v3.1")
|
||||
|| model.starts_with("kimi-k2.6")
|
||||
|| model.starts_with("kimi-k2.5")
|
||||
|| model.starts_with("glm-")
|
||||
}
|
||||
|
||||
fn modelstudio_model_supports_preserve_thinking(model: &str) -> bool {
|
||||
let model = model.trim().to_ascii_lowercase();
|
||||
model.starts_with("qwen3.7-max")
|
||||
|| model.starts_with("qwen3.7-plus")
|
||||
|| model.starts_with("qwen3.6-max-preview")
|
||||
|| model.starts_with("qwen3.6-plus")
|
||||
|| model.starts_with("qwen3.6-flash")
|
||||
|| model.starts_with("kimi-k2.6")
|
||||
|| model.starts_with("kimi-k2.7-code")
|
||||
}
|
||||
|
||||
fn modelstudio_model_supports_reasoning_effort(model: &str) -> bool {
|
||||
let model = model.trim().to_ascii_lowercase();
|
||||
model.starts_with("deepseek-v4") || matches!(model.as_str(), "glm-5.2" | "glm-5.1" | "glm-5")
|
||||
}
|
||||
|
||||
fn modelstudio_reasoning_effort_for_model(effort: &str) -> Option<&'static str> {
|
||||
match effort.trim().to_ascii_lowercase().as_str() {
|
||||
// Model Studio documents low and medium as aliases for high.
|
||||
"minimal" | "low" | "medium" | "mid" | "high" | "" => Some("high"),
|
||||
"xhigh" | "max" | "highest" | "ultracode" => Some("max"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Final reasoning-control pass shared by streaming and non-streaming Chat
|
||||
/// Completions requests. Route-specific shapers run after the generic provider
|
||||
/// layer so they can remove fields that are invalid for their exact endpoint.
|
||||
@@ -302,6 +466,7 @@ pub(super) fn apply_route_reasoning_controls(
|
||||
effort: Option<&str>,
|
||||
) {
|
||||
apply_reasoning_effort(body, effort, provider);
|
||||
apply_modelstudio_route_reasoning_controls(body, provider, base_url, model, effort);
|
||||
apply_minimax_route_reasoning_controls(body, provider, base_url, model, effort);
|
||||
apply_inkling_reasoning_effort(body, provider, model, effort);
|
||||
apply_openai_reasoning_effort(body, provider, model, effort);
|
||||
@@ -594,6 +759,7 @@ impl DeepSeekClient {
|
||||
let response = self.send_json_with_retry(url, body).await?;
|
||||
|
||||
let status = response.status();
|
||||
crate::client::record_provider_response(self.api_provider, status.as_u16());
|
||||
if !status.is_success() {
|
||||
let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
let error_text = sanitize_http_error_body(
|
||||
@@ -676,6 +842,7 @@ impl DeepSeekClient {
|
||||
let (response, stream_idle_timeout) = self.open_chat_stream_response(&url, &body).await?;
|
||||
|
||||
let status = response.status();
|
||||
crate::client::record_provider_response(self.api_provider, status.as_u16());
|
||||
if !status.is_success() {
|
||||
let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
let error_text = sanitize_http_error_body(
|
||||
@@ -2480,11 +2647,13 @@ fn should_replay_reasoning_content_for_provider_on_route(
|
||||
model: &str,
|
||||
effort: Option<&str>,
|
||||
) -> bool {
|
||||
// Both exact K3 routes are always-thinking. A stale caller may still carry
|
||||
// `off` before route normalization; retaining the assistant reasoning
|
||||
// trace is required for multi-turn/tool-call continuity regardless.
|
||||
// Both exact K3 routes and Model Studio's thinking-only models are
|
||||
// always-thinking. A stale caller may still carry `off` before route
|
||||
// normalization; retaining the assistant reasoning trace is required for
|
||||
// multi-turn/tool-call continuity regardless.
|
||||
if is_exact_direct_moonshot_k3_route(provider, base_url, model)
|
||||
|| is_exact_kimi_code_k3_route(provider, base_url, model)
|
||||
|| is_exact_modelstudio_thinking_only_route(provider, base_url, model)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -2504,6 +2673,19 @@ fn should_replay_reasoning_content_for_provider_on_route(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Model Studio replay is deliberately narrower than PR #5233 proposed:
|
||||
// only models Alibaba documents as accepting `preserve_thinking` get their
|
||||
// `reasoning_content` sent back. `deepseek-v3.1`, `deepseek-v3.2` and the
|
||||
// `glm-*` ids stay stripped until someone with a Model Studio key confirms
|
||||
// DashScope does not 400 on `reasoning_content` in input messages.
|
||||
// `deepseek-v4*` already replays via `requires_reasoning_content` above, so
|
||||
// this narrowing removes nothing that exists.
|
||||
if is_exact_modelstudio_chat_route(provider, base_url)
|
||||
&& modelstudio_model_supports_preserve_thinking(model)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if !provider_accepts_reasoning_content(provider) {
|
||||
// Generic non-DeepSeek model on a provider that rejects the field:
|
||||
// keep stripping it (preserves the #1542 fix). But a known DeepSeek
|
||||
@@ -5021,6 +5203,282 @@ mod alias_thinking_detection_tests {
|
||||
assert!(provider_accepts_reasoning_content(ApiProvider::Moonshot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_hybrid_routes_send_documented_thinking_controls() {
|
||||
let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
|
||||
for (effort, enabled) in [
|
||||
(None, true),
|
||||
(Some("low"), true),
|
||||
(Some("high"), true),
|
||||
(Some("xhigh"), true),
|
||||
(Some("off"), false),
|
||||
] {
|
||||
let mut body = json!({});
|
||||
apply_route_reasoning_controls(
|
||||
&mut body,
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"qwen3.7-plus",
|
||||
effort,
|
||||
);
|
||||
|
||||
assert_eq!(body["enable_thinking"], json!(enabled), "{effort:?}");
|
||||
assert_eq!(body["preserve_thinking"], json!(enabled), "{effort:?}");
|
||||
assert!(body.get("thinking").is_none(), "{effort:?}: {body}");
|
||||
assert!(body.get("reasoning_effort").is_none(), "{effort:?}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_deepseek_v4_maps_effort_to_documented_values() {
|
||||
let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
|
||||
for (requested, expected) in [("low", "high"), ("high", "high"), ("xhigh", "max")] {
|
||||
let mut body = json!({});
|
||||
apply_route_reasoning_controls(
|
||||
&mut body,
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"deepseek-v4-pro",
|
||||
Some(requested),
|
||||
);
|
||||
|
||||
assert_eq!(body["enable_thinking"], json!(true), "{requested}");
|
||||
assert_eq!(body["reasoning_effort"], json!(expected), "{requested}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_reasoning_controls_fail_closed_on_custom_gateways() {
|
||||
let mut body = json!({
|
||||
"enable_thinking": true,
|
||||
"preserve_thinking": true,
|
||||
"reasoning_effort": "high",
|
||||
});
|
||||
apply_route_reasoning_controls(
|
||||
&mut body,
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
"https://proxy.example/v1",
|
||||
"qwen3.7-plus",
|
||||
Some("high"),
|
||||
);
|
||||
|
||||
assert!(body.get("enable_thinking").is_none());
|
||||
assert!(body.get("preserve_thinking").is_none());
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_anthropic_identities_write_nothing_on_the_chat_path() {
|
||||
// The Messages adapter owns these two. If `wire = "openai"` ever routes
|
||||
// them through Chat Completions, the shaper must strip rather than
|
||||
// inherit the OpenAI-dialect fields — there is no provider-enum writer
|
||||
// left to re-add them.
|
||||
for provider in [
|
||||
ApiProvider::ModelstudioTokenPlanAnthropic,
|
||||
ApiProvider::ModelstudioCodingPlanAnthropic,
|
||||
] {
|
||||
for base_url in [
|
||||
crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
|
||||
crate::config::MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
|
||||
] {
|
||||
let mut body = json!({ "enable_thinking": true });
|
||||
apply_route_reasoning_controls(
|
||||
&mut body,
|
||||
provider,
|
||||
base_url,
|
||||
"qwen3.7-plus",
|
||||
Some("high"),
|
||||
);
|
||||
assert_eq!(body, json!({}), "{provider:?} {base_url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_qwen38_route_classifies_reasoning_and_replays_history() {
|
||||
let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
|
||||
for model in ["qwen3.8-max", "qwen3.8-max-preview"] {
|
||||
// qwen3.8 is thinking-only. Effort selection must never hide its
|
||||
// separate reasoning stream, including the stale `off` state
|
||||
// that can arrive before route normalization.
|
||||
assert_eq!(
|
||||
reasoning_stream_style_for_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
model,
|
||||
None,
|
||||
),
|
||||
ReasoningStreamStyle::SeparateField,
|
||||
"{model}"
|
||||
);
|
||||
for effort in [None, Some("off"), Some("high"), Some("xhigh")] {
|
||||
assert!(
|
||||
should_replay_reasoning_content_for_provider_on_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
model,
|
||||
effort,
|
||||
),
|
||||
"{model} {effort:?}"
|
||||
);
|
||||
}
|
||||
// ...and no enable/disable switch is ever sent for them.
|
||||
let mut body = json!({});
|
||||
apply_route_reasoning_controls(
|
||||
&mut body,
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
model,
|
||||
Some("off"),
|
||||
);
|
||||
assert!(body.get("enable_thinking").is_none(), "{model}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_hybrid_route_classifies_reasoning_and_replays_history() {
|
||||
let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
|
||||
assert_eq!(
|
||||
reasoning_stream_style_for_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"qwen3.7-plus",
|
||||
None,
|
||||
),
|
||||
ReasoningStreamStyle::SeparateField,
|
||||
);
|
||||
assert!(should_replay_reasoning_content_for_provider_on_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"qwen3.7-plus",
|
||||
None,
|
||||
));
|
||||
assert!(!should_replay_reasoning_content_for_provider_on_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"qwen3.7-plus",
|
||||
Some("off"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_replay_stays_narrow_until_a_live_key_confirms_it() {
|
||||
// Deliberately narrower than PR #5233: only `preserve_thinking` models
|
||||
// replay. GLM and DeepSeek-V3.x on Model Studio stay stripped until
|
||||
// someone with a key confirms DashScope accepts `reasoning_content` in
|
||||
// input messages. deepseek-v4* is unaffected — it replays through
|
||||
// `requires_reasoning_content` on every provider.
|
||||
let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
|
||||
for model in ["glm-5.2", "deepseek-v3.2", "deepseek-v3.1"] {
|
||||
assert!(
|
||||
!should_replay_reasoning_content_for_provider_on_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
model,
|
||||
None,
|
||||
),
|
||||
"{model}"
|
||||
);
|
||||
}
|
||||
assert!(should_replay_reasoning_content_for_provider_on_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"deepseek-v4-pro",
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_coding_plan_chat_route_is_classified_for_all_supported_identities() {
|
||||
// The picker represents Coding Plan as mode = "coding-plan" under
|
||||
// the primary provider id, so the chat client receives
|
||||
// ModelstudioTokenPlan with the Coding Plan URL. Direct configuration
|
||||
// also retains the legacy ModelstudioCodingPlan identity.
|
||||
let base_url = crate::config::DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL;
|
||||
for provider in [
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
ApiProvider::ModelstudioCodingPlan,
|
||||
] {
|
||||
let mut body = json!({});
|
||||
apply_route_reasoning_controls(
|
||||
&mut body,
|
||||
provider,
|
||||
base_url,
|
||||
"qwen3.7-plus",
|
||||
Some("high"),
|
||||
);
|
||||
|
||||
assert_eq!(body["enable_thinking"], json!(true), "{provider:?}");
|
||||
assert_eq!(body["preserve_thinking"], json!(true), "{provider:?}");
|
||||
assert_eq!(
|
||||
reasoning_stream_style_for_route(provider, base_url, "qwen3.7-plus", None),
|
||||
ReasoningStreamStyle::SeparateField,
|
||||
"{provider:?}",
|
||||
);
|
||||
assert!(should_replay_reasoning_content_for_provider_on_route(
|
||||
provider,
|
||||
base_url,
|
||||
"qwen3.7-plus",
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_workspace_scoped_token_plan_route_is_recognized() {
|
||||
let workspace_url =
|
||||
"https://workspace-123.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
|
||||
assert_eq!(
|
||||
reasoning_stream_style_for_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
workspace_url,
|
||||
"qwen3.8-max",
|
||||
None,
|
||||
),
|
||||
ReasoningStreamStyle::SeparateField,
|
||||
);
|
||||
assert!(should_replay_reasoning_content_for_provider_on_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
workspace_url,
|
||||
"qwen3.8-max",
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelstudio_kimi_k27_code_is_thinking_only_and_preserves_trace() {
|
||||
// NOTE: unlike the qwen3.8 pair, this classification is asserted by
|
||||
// PR #5233 rather than corroborated by models_dev.bundled.json, which
|
||||
// lists kimi-k2.7-code with `reasoning: true` and no `always_on`.
|
||||
let base_url = crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL;
|
||||
let mut body = json!({});
|
||||
apply_route_reasoning_controls(
|
||||
&mut body,
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"kimi-k2.7-code",
|
||||
Some("off"),
|
||||
);
|
||||
|
||||
assert!(body.get("enable_thinking").is_none());
|
||||
assert_eq!(body["preserve_thinking"], json!(true));
|
||||
assert_eq!(
|
||||
reasoning_stream_style_for_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"kimi-k2.7-code",
|
||||
None,
|
||||
),
|
||||
ReasoningStreamStyle::SeparateField,
|
||||
);
|
||||
assert!(should_replay_reasoning_content_for_provider_on_route(
|
||||
ApiProvider::ModelstudioTokenPlan,
|
||||
base_url,
|
||||
"kimi-k2.7-code",
|
||||
Some("off"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_classifies_moonshot_kimi_as_reasoning() {
|
||||
// #3016: without this, Kimi thinking leaked into answer text.
|
||||
|
||||
@@ -183,6 +183,7 @@ impl DeepSeekClient {
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
crate::client::record_provider_response(self.api_provider, status.as_u16());
|
||||
if !status.is_success() {
|
||||
let raw = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
anyhow::bail!("Responses API error (HTTP {status}): {raw}");
|
||||
|
||||
@@ -5569,6 +5569,11 @@ fn tool_ask_rule_decision_for_context(
|
||||
} else if decision.requires_approval {
|
||||
Some(ToolAskRuleDecision::Prompt(decision.reason().to_string()))
|
||||
} else if decision.matched_action == Some(codewhale_execpolicy::PermissionAction::Allow) {
|
||||
// Count only. Never `matched_rule`, never `reason()`, never the
|
||||
// command or its argv: `auto_allow` patterns are user-authored command
|
||||
// strings.
|
||||
codewhale_telemetry::session_counters()
|
||||
.bump(codewhale_telemetry::Counter::ApprovalAutoAllowed);
|
||||
Some(ToolAskRuleDecision::Allow)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -472,6 +472,14 @@ impl Engine {
|
||||
};
|
||||
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
// The surface-agnostic choke point for every tool call, so this one
|
||||
// bump covers exec and the CLI as well as the TUI. `memory_search` is
|
||||
// counted here for the same reason — one site, not one per tool.
|
||||
let telemetry = codewhale_telemetry::session_counters();
|
||||
telemetry.bump(codewhale_telemetry::Counter::ToolCalls);
|
||||
if tool_name == "memory_search" {
|
||||
telemetry.bump(codewhale_telemetry::Counter::MemorySearch);
|
||||
}
|
||||
match &outcome {
|
||||
Ok(result) => {
|
||||
tracing::debug!(
|
||||
@@ -495,6 +503,18 @@ impl Engine {
|
||||
ToolError::NotAvailable { .. } => "not_available",
|
||||
ToolError::PermissionDenied { .. } => "permission_denied",
|
||||
};
|
||||
// The discriminant and nothing else. `ToolError::PathEscape`'s
|
||||
// `Display` *is* an absolute path, and several sibling
|
||||
// variants render a literal source fragment the model emitted.
|
||||
match err {
|
||||
ToolError::PermissionDenied { .. } => {
|
||||
telemetry.bump_error(codewhale_telemetry::ErrorCounter::ToolDeniedByPolicy)
|
||||
}
|
||||
ToolError::Timeout { .. } => {
|
||||
telemetry.bump_error(codewhale_telemetry::ErrorCounter::ToolTimeout);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "engine.tool_execution",
|
||||
tool = %tool_name,
|
||||
|
||||
@@ -785,6 +785,10 @@ pub(crate) fn preflight_route(
|
||||
} else if crate::config::has_api_key_for(&scoped, identity.provider) {
|
||||
CredentialReadiness::Configured
|
||||
} else {
|
||||
// The discriminant only. `Missing { detail }` names the provider table
|
||||
// key, which for a custom route is the customer's own string.
|
||||
codewhale_telemetry::session_counters()
|
||||
.bump_error(codewhale_telemetry::ErrorCounter::AuthPreflightFailed);
|
||||
CredentialReadiness::Missing {
|
||||
detail: format!("no credential configured for `{}`", identity.key),
|
||||
}
|
||||
|
||||
@@ -367,6 +367,11 @@ impl FleetManager {
|
||||
descriptor: ManagedFleetRunDescriptor,
|
||||
) -> Result<FleetRunReport> {
|
||||
validate_task_spec_document(&doc)?;
|
||||
// The single funnel: `create_run` and `create_queued_run` both land
|
||||
// here, so counting at either of those would double-count a plain
|
||||
// `fleet run`. Counted after validation, so a rejected spec is not a
|
||||
// dispatch.
|
||||
codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::FleetDispatch);
|
||||
worker_runtime::canonicalize_fleet_task_roles(&mut doc.tasks);
|
||||
let roster = self.agent_roster();
|
||||
worker_runtime::validate_task_agent_profiles(&doc.tasks, roster.members())?;
|
||||
|
||||
@@ -17500,3 +17500,67 @@ mod telemetry_surface_tests {
|
||||
assert!(RunTerminationReason::Resolved.is_success());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod telemetry_counter_tests {
|
||||
use codewhale_telemetry::counters::http_status_counter;
|
||||
use codewhale_telemetry::{ErrorCounter, session_counters};
|
||||
|
||||
#[test]
|
||||
fn a_custom_provider_is_recorded_as_the_literal_custom() {
|
||||
// This is the single most likely leak in the feature: the persistence
|
||||
// identity, the exec stream meta, and the planned route's effective
|
||||
// label all return the customer's own `[providers.<name>]` table key
|
||||
// when the route is custom, and `/status` already prints it. The
|
||||
// recording API takes a `ProviderKind` by value so none of them fit.
|
||||
crate::client::record_provider_response(crate::config::ApiProvider::Custom, 200);
|
||||
let providers = session_counters().providers();
|
||||
assert!(
|
||||
providers.iter().any(|name| name == "custom"),
|
||||
"expected the literal `custom`, got {providers:?}"
|
||||
);
|
||||
|
||||
let closed: std::collections::BTreeSet<&str> = codewhale_config::ProviderKind::ALL
|
||||
.iter()
|
||||
.map(|kind| kind.as_str())
|
||||
.collect();
|
||||
for name in &providers {
|
||||
assert!(
|
||||
closed.contains(name.as_str()),
|
||||
"`{name}` is not a ProviderKind; a table name reached the provider set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_status_classes_land_in_the_right_error_counter() {
|
||||
// Captured from the response, before any `LlmError` is built: every
|
||||
// variant of that error carries the raw provider body verbatim.
|
||||
assert_eq!(http_status_counter(200), None);
|
||||
assert_eq!(http_status_counter(304), None);
|
||||
assert_eq!(
|
||||
http_status_counter(429),
|
||||
Some(ErrorCounter::ProviderHttp4xx)
|
||||
);
|
||||
assert_eq!(
|
||||
http_status_counter(503),
|
||||
Some(ErrorCounter::ProviderHttp5xx)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_turn_wall_histogram_buckets_by_wall_clock_not_per_turn_events() {
|
||||
// A histogram, never a timestamped series: a stream of per-turn
|
||||
// durations reconstructs a session's working rhythm.
|
||||
let mut wall = codewhale_telemetry::TurnWall::default();
|
||||
wall.observe_secs(0);
|
||||
wall.observe_secs(4);
|
||||
wall.observe_secs(5);
|
||||
wall.observe_secs(119);
|
||||
wall.observe_secs(120);
|
||||
assert_eq!(wall.lt_5s, 2);
|
||||
assert_eq!(wall.five_to_thirty, 1);
|
||||
assert_eq!(wall.thirty_to_onetwenty, 1);
|
||||
assert_eq!(wall.gte_120s, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4502,6 +4502,13 @@ fn snapshot_from_config(
|
||||
}
|
||||
if let Some(conn) = pool.connections.get(name) {
|
||||
snapshot.connected = conn.is_ready();
|
||||
if snapshot.connected {
|
||||
// A count of connected servers and nothing else. The
|
||||
// name, the command or URL, and the error string are
|
||||
// user-chosen and routinely name internal infra.
|
||||
codewhale_telemetry::session_counters()
|
||||
.bump(codewhale_telemetry::Counter::McpServerConnected);
|
||||
}
|
||||
snapshot.tools = conn
|
||||
.tools()
|
||||
.iter()
|
||||
|
||||
@@ -866,7 +866,14 @@ impl ToolSpec for WorkflowTool {
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let state = shared_workflow_state(&context.workspace);
|
||||
attach_bound_workflow_lifecycles(context, &state)?;
|
||||
match parse_workflow_action(&input)? {
|
||||
// Keyed off the parsed `WorkflowAction` discriminant, never off
|
||||
// `input["action"]`. The JSON Schema published to the model is a
|
||||
// declaration, not a guard: the real parse also accepts `spawn`,
|
||||
// `wait`, `list`, `inspect`, `stop`, and `abort`, and its reject arm
|
||||
// embeds the model's string verbatim.
|
||||
let action = parse_workflow_action(&input)?;
|
||||
codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::WorkflowRun);
|
||||
match action {
|
||||
WorkflowAction::Start => {
|
||||
let wait = optional_bool(&input, "wait", false);
|
||||
start_workflow(
|
||||
|
||||
@@ -1324,6 +1324,8 @@ pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuActio
|
||||
}
|
||||
}
|
||||
ContextMenuAction::OpenCommandPalette => {
|
||||
codewhale_telemetry::session_counters()
|
||||
.bump(codewhale_telemetry::Counter::CommandPaletteOpen);
|
||||
app.view_stack.push(CommandPaletteView::new_for_locale(
|
||||
app.ui_locale,
|
||||
build_command_palette_entries(
|
||||
|
||||
@@ -1774,6 +1774,7 @@ fn apply_agent_spawned_status_and_observer(
|
||||
prompt_summary: &str,
|
||||
) {
|
||||
let label = app.ensure_agent_label(agent_id);
|
||||
codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::SubagentSpawn);
|
||||
app.status_message = Some(format!("{label} starting: {prompt_summary}"));
|
||||
if let Err(error) =
|
||||
execute_subagent_observer_hook(app, HookEvent::SubagentSpawn, agent_id, "prompt", prompt)
|
||||
@@ -4199,6 +4200,19 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// Counted here, at the caller, never inside
|
||||
// `execute_turn_end_observer_hook`: that function's
|
||||
// first statement returns early for anyone with no
|
||||
// TurnEnd hooks, and the natural future optimization
|
||||
// hoists that check up to this call site — which would
|
||||
// silently zero the counter for every user who does
|
||||
// not use hooks.
|
||||
{
|
||||
let telemetry = codewhale_telemetry::session_counters();
|
||||
telemetry.bump(codewhale_telemetry::Counter::Turns);
|
||||
telemetry.observe_turn_secs(turn_elapsed.as_secs());
|
||||
}
|
||||
|
||||
if let Err(error) = execute_turn_end_observer_hook(
|
||||
app,
|
||||
completed_turn.as_ref(),
|
||||
@@ -4683,6 +4697,11 @@ async fn run_event_loop(
|
||||
intent_summary,
|
||||
approval_force_prompt,
|
||||
} => {
|
||||
// A count and nothing else. The tool name, the
|
||||
// description, the input, and the matched rule are all
|
||||
// user- or model-authored strings.
|
||||
codewhale_telemetry::session_counters()
|
||||
.bump(codewhale_telemetry::Counter::ApprovalModalShown);
|
||||
if app.remote_control.blocks_local_input() {
|
||||
let gate = app.remote_control.record_remote_approval(
|
||||
&id,
|
||||
@@ -6203,6 +6222,8 @@ async fn run_event_loop(
|
||||
if app.view_stack.is_empty() && app.kill_to_end_of_line() {
|
||||
continue;
|
||||
}
|
||||
codewhale_telemetry::session_counters()
|
||||
.bump(codewhale_telemetry::Counter::CommandPaletteOpen);
|
||||
app.view_stack.push(CommandPaletteView::new_for_locale(
|
||||
app.ui_locale,
|
||||
build_command_palette_entries(
|
||||
|
||||
Reference in New Issue
Block a user