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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,16 @@ async fn delete_service(
manager.delete_service(&service_id, &etag).await
}

#[tauri::command]
async fn service_statistics(
service_id: String,
from: String,
to: String,
manager: State<'_, Arc<CoreManager>>,
) -> Result<serde_json::Value, String> {
manager.service_statistics(&service_id, &from, &to).await
}

#[tauri::command]
async fn pricing(
operation: String,
Expand Down Expand Up @@ -1442,6 +1452,7 @@ pub fn run() {
delete_service,
get_service_usage,
pricing,
service_statistics,
reset_service_usage,
test_service,
probe_service_models,
Expand Down
40 changes: 39 additions & 1 deletion apps/desktop/src-tauri/src/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1659,6 +1659,24 @@ impl CoreManager {
Ok(())
}

pub async fn service_statistics(
&self,
service_id: &str,
from: &str,
to: &str,
) -> Result<serde_json::Value, String> {
validate_resource_id(service_id)?;
let path = format!(
"/control/v1/services/{service_id}/statistics?from={}&to={}",
percent_encode_query(from),
percent_encode_query(to)
);
let (_, body) = self
.authenticated_control(Method::GET, &path, None, None)
.await?;
serde_json::from_slice(&body).map_err(|_| "statistics returned invalid JSON".into())
}

pub async fn pricing(
&self,
operation: &str,
Expand Down Expand Up @@ -2522,7 +2540,10 @@ fn control_request_timeout(method: &Method, path: &str) -> Duration {
if (*method == Method::GET || *method == Method::POST) && is_subscription_usage_path(path) {
return SUBSCRIPTION_USAGE_TIMEOUT;
}
if *method == Method::GET && is_request_record_list_path(path) {
if *method == Method::GET
&& (is_request_record_list_path(path)
|| (path.starts_with(&format!("{SERVICES_PATH}/")) && path.ends_with("/statistics")))
{
return REQUEST_LIST_TIMEOUT;
}
REQUEST_TIMEOUT
Expand Down Expand Up @@ -5434,6 +5455,23 @@ mod tests {
assert!(parse_authorization_session_value(&device).is_err());
}

#[test]
fn service_statistics_allow_aggregation_time_with_query_parameters() {
let path = "/control/v1/services/service_test/statistics?from=2026-09-01T00%3A00%3A00Z&to=2026-09-25T00%3A00%3A00Z";
assert_eq!(
control_request_timeout(&Method::GET, path),
REQUEST_LIST_TIMEOUT
);
assert_eq!(
control_request_timeout(&Method::POST, path),
REQUEST_TIMEOUT
);
assert_eq!(
control_request_timeout(&Method::GET, "/control/v1/services/service_test"),
REQUEST_TIMEOUT
);
}

#[test]
fn provider_tests_allow_the_core_deadline_to_finish() {
assert_eq!(
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/ServiceManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
validProxyDraft,
type ProxyDraft,
} from "./service-proxy-model";
import { ServiceStatisticsEntry } from "./ServiceStatisticsDialog";
import { ServiceTestDialog } from "./ServiceTestDialog";
import { PricingWorkspace, ServiceBillingMeter } from "./PricingWorkspace";
import { useServiceOrder } from "./use-service-order";
Expand Down Expand Up @@ -1869,6 +1870,10 @@ export function ServiceManager({
}
actions={
<>
<ServiceStatisticsEntry
service={service}
disabled={!isReady || acting}
/>
<IconButton
label={t("serviceTest.testNamed", {
name: service.name,
Expand Down
127 changes: 127 additions & 0 deletions apps/desktop/src/ServiceStatisticsDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { beforeEach, afterEach, expect, it, vi } from "vitest";
import type { Service } from "./service-model";
const mocks = vi.hoisted(() => ({ invoke: vi.fn(), billing: vi.fn() }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("./pricing-bridge", () => ({ getServiceBilling: mocks.billing }));
import { ServiceStatisticsEntry } from "./ServiceStatisticsDialog";
let root: Root;
let host: HTMLDivElement;
const service = {
id: "service_price",
name: "Price test",
models: ["gpt-6-astra"],
} as Service;
beforeEach(() => {
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
mocks.billing.mockResolvedValue({
config: {
provider: "",
bindings: {},
billing_day: 1,
time_zone: "UTC",
monthly_budget_usd: "",
},
});
mocks.invoke.mockImplementation(
async (command: string, args: { operation?: string }) => {
if (command === "service_statistics") return undefined;
if (args.operation === "catalog")
return {
prices: [
{
provider: "openai",
model: "gpt-6-astra",
name: "GPT-6 Astra",
expression:
'len <= 272000 ? tier("small", p * 10 + cr * 1 + cc * 12.5 + c * 50) : tier("large", p * 20 + cr * 2 + cc * 25 + c * 75)',
},
],
};
return { ok: true };
},
);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.clearAllMocks();
});
const button = (text: string) =>
[...document.querySelectorAll<HTMLButtonElement>("button")].find(
(b) => b.textContent === text,
)!;
async function openPrices(models: string[]) {
await act(async () =>
root.render(
<ServiceStatisticsEntry
service={{ ...service, models }}
disabled={false}
/>,
),
);
await act(async () =>
document
.querySelector<HTMLButtonElement>(
'[aria-label="缓存与费用 · Price test"]',
)!
.click(),
);
await act(async () =>
button("模型价格").dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, button: 0 }),
),
);
}
it("shows effective catalog tiers before offering explicit channel overrides", async () => {
await openPrices(["gpt-6-astra"]);
expect(document.body.textContent).toContain(
"来源:价格目录 · OpenAI · GPT-6 Astra",
);
expect(document.body.textContent).toContain("$12.5");
expect(document.body.textContent).toContain("$75");
expect(document.querySelector('[aria-label="输入价格"]')).toBeNull();
await act(async () => button("设置渠道自定义价格").click());
for (const label of ["输入", "输出", "缓存读取", "缓存写入"]) {
const input = document.querySelector<HTMLInputElement>(
`[aria-label="${label}价格"]`,
)!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!.call(input, "2");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
await act(async () => button("保存模型价格").click());
expect(mocks.invoke).toHaveBeenCalledWith(
"pricing",
expect.objectContaining({
operation: "configure",
input: expect.objectContaining({
overrides: {
"gpt-6-astra": {
input: "2",
output: "2",
cache_read: "2",
cache_write: "2",
},
},
}),
}),
);
expect(document.body.textContent).toContain("来源:渠道自定义价格");
});
it("marks an unmatched model unpriced instead of showing zero or unrelated catalog rates", async () => {
await openPrices(["deepseek-v4.1-flash"]);
expect(document.body.textContent).toContain("未配置价格");
expect(document.body.textContent).not.toContain("$10");
});
Loading