Feat/pricing config enhancement (#781)

* feat(db): add pricing config fields to proxy_config table

- Add default_cost_multiplier field per app type
- Add pricing_model_source field (request/response)
- Add request_model field to proxy_request_logs table
- Implement schema migration v5

* feat(api): add pricing config commands and provider meta fields

- Add get/set commands for default cost multiplier
- Add get/set commands for pricing model source
- Extend ProviderMeta with cost_multiplier and pricing_model_source
- Register new commands in Tauri invoke handler

* fix(proxy): apply cost multiplier to total cost only

- Move multiplier calculation from per-item to total cost
- Add resolve_pricing_config for provider-level override
- Include request_model and cost_multiplier in usage logs
- Return new fields in get_request_logs API

* feat(ui): add pricing config UI and usage log enhancements

- Add pricing config section to provider advanced settings
- Refactor PricingConfigPanel to compact table layout
- Display all three apps (Claude/Codex/Gemini) in one view
- Add multiplier column and request model display to logs
- Add frontend API wrappers for pricing config

* feat(i18n): add pricing config translations

- Add zh/en/ja translations for pricing defaults config
- Add translations for multiplier, requestModel, responseModel
- Add provider pricing config translations

* fix(pricing): align backfill cost calculation with real-time logic

- Fix backfill to deduct cache_read_tokens from input (avoid double billing)
- Apply multiplier only to total cost, not to each item
- Add multiplier display in request detail panel with i18n support
- Use AppError::localized for backend error messages
- Fix init_proxy_config_rows to use per-app default values
- Fix silent failure in set_default_cost_multiplier/set_pricing_model_source
- Add clippy allow annotation for test mutex across await

* style: format code with cargo fmt and prettier

* fix(tests): correct error type assertions in proxy DAO tests

The tests expected AppError::InvalidInput but the DAO functions use
AppError::localized() which returns AppError::Localized variant.
Updated assertions to match the correct error type with key validation.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Dex Miller
2026-01-27 10:43:05 +08:00
committed by GitHub
parent c00f431d67
commit 785e1b5add
27 changed files with 2123 additions and 283 deletions

View File

@@ -0,0 +1,83 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const mutateAsyncMock = vi.fn();
const testMutateAsyncMock = vi.fn();
const scanMutateAsyncMock = vi.fn();
vi.mock("@/hooks/useGlobalProxy", () => ({
useGlobalProxyUrl: () => ({ data: "http://127.0.0.1:7890", isLoading: false }),
useSetGlobalProxyUrl: () => ({
mutateAsync: mutateAsyncMock,
isPending: false,
}),
useTestProxy: () => ({
mutateAsync: testMutateAsyncMock,
isPending: false,
}),
useScanProxies: () => ({
mutateAsync: scanMutateAsyncMock,
isPending: false,
}),
}));
describe("GlobalProxySettings", () => {
beforeEach(() => {
mutateAsyncMock.mockReset();
testMutateAsyncMock.mockReset();
scanMutateAsyncMock.mockReset();
});
it("renders proxy URL input with saved value", async () => {
render(<GlobalProxySettings />);
const urlInput = screen.getByPlaceholderText(
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
);
// URL 对象会在末尾添加斜杠
await waitFor(() =>
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
);
});
it("saves proxy URL when save button is clicked", async () => {
render(<GlobalProxySettings />);
const urlInput = screen.getByPlaceholderText(
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
);
fireEvent.change(urlInput, { target: { value: "http://localhost:8080" } });
const saveButton = screen.getByRole("button", { name: "common.save" });
fireEvent.click(saveButton);
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled());
// 没有用户名时URL 不经过 URL 对象解析,所以没有尾部斜杠
expect(mutateAsyncMock).toHaveBeenCalledWith("http://localhost:8080");
});
it("clears proxy URL when clear button is clicked", async () => {
render(<GlobalProxySettings />);
const urlInput = screen.getByPlaceholderText(
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
);
// Wait for initial value to load
await waitFor(() =>
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
);
// Click clear button
const clearButton = screen.getByTitle("settings.globalProxy.clear");
fireEvent.click(clearButton);
expect(urlInput).toHaveValue("");
});
});