feat: improve request pricing, model diagnostics, and client discovery
- Align response model handling with CPA-Manager-Plus and flag routing mismatches - Add per-request costs and CSV export; fix mixed-context cost aggregation - Display API-reported access types and move providers next to timestamps - Use white table backgrounds and simplify cost and status displays - Expand client discovery paths, validate executables, and prioritize installed clients - Add model, pricing, client discovery, and UI regression tests
This commit is contained in:
1 parent
0e7437493d
commit
dfba9b019f
20 files changed
+652
-152
No files matched your search
@@ -8,7 +8,7 @@ const path = require('node:path');
|
||||
|
||||
const base = process.env.AGENT_CLIENT_TEST_URL || 'http://127.0.0.1:1421';
|
||||
const storageKey = 'cpa-gui.agent-visible-clients.v1';
|
||||
const automaticClients = ['Claude Code', 'Claude Desktop', 'Codex', 'OpenCode', 'Pi'];
|
||||
const automaticClients = ['Claude Code', 'Codex', 'OpenCode', 'Claude Desktop', 'Pi'];
|
||||
const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
|
||||
(async () => {
|
||||
@@ -89,6 +89,19 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
assert.equal(await page.locator('.agent-list-items button.active').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await stored(), null, 'automatic defaults do not become a custom preference');
|
||||
|
||||
// Sorting applies to custom lists and detection refresh, without changing membership or selection.
|
||||
const customIds = ['claude-desktop', 'codex', 'hermes'];
|
||||
await open('mixed-clients', JSON.stringify(customIds));
|
||||
await waitClients(['Codex', 'Claude Desktop', 'Hermes Agent']);
|
||||
assert.deepEqual(await page.locator('.agent-list-items strong').allTextContents(), ['Codex', 'Claude Desktop', 'Hermes Agent']);
|
||||
await refresh({ hermes: { installed: true } });
|
||||
await waitClients(['Codex', 'Hermes Agent', 'Claude Desktop']);
|
||||
assert.deepEqual(await page.locator('.agent-list-items strong').allTextContents(), ['Codex', 'Hermes Agent', 'Claude Desktop']);
|
||||
assert.equal(await activeClient(), 'Codex');
|
||||
assert.deepEqual(JSON.parse(await stored()), customIds, 'sorting must not rewrite saved membership');
|
||||
await open();
|
||||
await waitClients(automaticClients);
|
||||
|
||||
// Search reaches uninstalled clients; a cancelled draft never affects navigation or storage.
|
||||
await manage().click();
|
||||
assert.equal(await dialog().getByRole('checkbox').count(), 13);
|
||||
@@ -149,12 +162,12 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
await automatic().click();
|
||||
await closeAndFocus(() => save().click());
|
||||
assert.equal(await stored(), null, 'saving automatic mode clears the custom preference');
|
||||
await waitClients([...automaticClients, 'Kimi Code', 'Hermes Agent']);
|
||||
await waitClients(['Claude Code', 'Codex', 'OpenCode', 'Kimi Code', 'Claude Desktop', 'Pi', 'Hermes Agent']);
|
||||
await refresh({ ...override,
|
||||
'claude-code': { installed: false, configured: false, configExists: false },
|
||||
openclaw: { installed: true },
|
||||
});
|
||||
await waitClients(['Claude Desktop', 'Codex', 'OpenCode', 'Pi', 'Kimi Code', 'OpenClaw', 'Hermes Agent']);
|
||||
await waitClients(['Codex', 'OpenCode', 'Kimi Code', 'OpenClaw', 'Claude Desktop', 'Pi', 'Hermes Agent']);
|
||||
|
||||
// Detection failure and a clean machine still offer a usable selected client and the full catalog.
|
||||
await open('not-installed&no-plugin&fresh');
|
||||
|
||||
Vendored
+1
@@ -55,6 +55,7 @@ const records = Array.from({ length: 400 }, (_, index) => ({
|
||||
failure_status: index % 4 === 0 ? 429 : 0,
|
||||
failure_body: index % 4 === 0 ? 'Rate limit exceeded' : '',
|
||||
provider: 'test-provider',
|
||||
auth_type: index % 2 ? 'oauth' : 'apikey',
|
||||
model: index % 3 ? 'test-model' : 'secondary-model',
|
||||
alias: '',
|
||||
reasoning_effort: 'high',
|
||||
|
||||
+4
-1
@@ -12,6 +12,7 @@ localStorage.setItem('cpa-gui.usage-events-visible-cols.v3', JSON.stringify([
|
||||
'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider',
|
||||
]));
|
||||
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v2');
|
||||
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v4');
|
||||
localStorage.removeItem('cpa-gui.usage-events-col-widths.v2');
|
||||
document.documentElement.dataset.theme = params.get('theme') || 'light';
|
||||
|
||||
@@ -36,7 +37,9 @@ const record = (id: string, model: string, alias: string, response_model?: strin
|
||||
canceled: false,
|
||||
failure_status: 0,
|
||||
failure_body: '',
|
||||
provider: 'test-provider',
|
||||
provider: 'codex',
|
||||
auth_type: id === 'differing' ? 'apikey' : id === 'matching' ? 'oauth' : undefined,
|
||||
cost: id === 'differing' ? { total: 0.0042, pricing_model: 'gpt-6-astra' } : id === 'matching' ? { total: 0, pricing_model: 'gpt-6-sol' } : null,
|
||||
model,
|
||||
alias,
|
||||
...(response_model === undefined ? {} : { response_model }),
|
||||
|
||||
@@ -6,10 +6,10 @@ const path = require('node:path');
|
||||
|
||||
const base = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:1421';
|
||||
const locales = {
|
||||
'zh-CN': { request: '请求模型', upstream: '上游模型', response: '上游响应' },
|
||||
en: { request: 'Requested model', upstream: 'Upstream model', response: 'Upstream response' },
|
||||
ja: { request: 'リクエストモデル', upstream: '上流モデル', response: '上流レスポンス' },
|
||||
'zh-TW': { request: '請求模型', upstream: '上游模型', response: '上游響應' },
|
||||
'zh-CN': { request: '请求模型', upstream: '路由模型', response: '响应模型' },
|
||||
en: { request: 'Requested model', upstream: 'Routed model', response: 'Response model' },
|
||||
ja: { request: 'リクエストモデル', upstream: 'ルーティングモデル', response: '応答モデル' },
|
||||
'zh-TW': { request: '請求模型', upstream: '路由模型', response: '響應模型' },
|
||||
};
|
||||
|
||||
function parseCsv(text) {
|
||||
@@ -70,7 +70,7 @@ function parseCsv(text) {
|
||||
main: cell.querySelector(':scope > strong')?.textContent || '',
|
||||
sublines: Array.from(cell.querySelectorAll(':scope > small')).map(item => item.textContent || ''),
|
||||
response: cell.querySelector(':scope > .usage-response-model')?.textContent || '',
|
||||
title: cell.getAttribute('title'),
|
||||
title: cell.getAttribute('title')?.split('\n').slice(0, 3).join('\n') ?? null,
|
||||
})));
|
||||
const assertNoHorizontalPageOverflow = async (label) => {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
@@ -89,26 +89,35 @@ function parseCsv(text) {
|
||||
await open('zh-CN');
|
||||
assert.equal(await modelCells().count(), 9, 'Every fixture record renders a model cell');
|
||||
const zhDetails = await modelDetails();
|
||||
assert.match(await modelCells().first().getAttribute('title'), /response_model.*上游声明/);
|
||||
assert.deepEqual(await page.locator('.usage-events-table th').evaluateAll(cells => cells.slice(0, 2).map(cell => cell.className.split(' ')[0])), ['usage-th-time', 'usage-th-provider']);
|
||||
assert.equal(await page.locator('.usage-td-provider strong').first().textContent(), 'Codex');
|
||||
assert.equal(await page.locator('.usage-access-type').first().textContent(), 'API');
|
||||
assert.equal(await page.locator('.usage-access-type').nth(1).textContent(), 'OAuth');
|
||||
assert.equal(await page.locator('.usage-access-type').nth(2).textContent(), '未记录接入方式');
|
||||
assert.deepEqual(zhDetails[0], {
|
||||
main: 'astra',
|
||||
sublines: ['gpt-6-astra', '↳ 上游响应: gpt-5.6-luna'],
|
||||
response: '↳ 上游响应: gpt-5.6-luna',
|
||||
title: '请求模型: astra\n上游模型: gpt-6-astra\n上游响应: gpt-5.6-luna',
|
||||
sublines: ['gpt-6-astra', '响应模型: gpt-5.6-luna'],
|
||||
response: '响应模型: gpt-5.6-luna',
|
||||
title: '请求模型: astra\n路由模型: gpt-6-astra\n响应模型: gpt-5.6-luna',
|
||||
}, 'Differing alias, upstream model, and upstream response remain distinguishable');
|
||||
assert.deepEqual(zhDetails[1], {
|
||||
main: 'same-alias',
|
||||
sublines: ['gpt-6-sol', '↳ 上游响应: gpt-6-sol'],
|
||||
response: '↳ 上游响应: gpt-6-sol',
|
||||
title: '请求模型: same-alias\n上游模型: gpt-6-sol\n上游响应: gpt-6-sol',
|
||||
}, 'A response equal to the upstream model still renders');
|
||||
assert.deepEqual(zhDetails[2].sublines, ['openai/gpt-4o-latest', '↳ 上游响应: gpt-4o-2024-08-06'], 'Snapshot and prefix model names preserve the existing upstream subline');
|
||||
assert.equal(zhDetails[2].title, '请求模型: gpt4o\n上游模型: openai/gpt-4o-latest\n上游响应: gpt-4o-2024-08-06');
|
||||
sublines: ['gpt-6-sol'],
|
||||
response: '',
|
||||
title: '请求模型: same-alias\n路由模型: gpt-6-sol',
|
||||
}, 'A response equal to the routed model is not repeated');
|
||||
assert.deepEqual(zhDetails[2].sublines, ['openai/gpt-4o-latest', '响应模型: gpt-4o-2024-08-06'], 'Snapshot and prefix model names preserve the existing upstream subline');
|
||||
assert.equal(zhDetails[2].title, '请求模型: gpt4o\n路由模型: openai/gpt-4o-latest\n响应模型: gpt-4o-2024-08-06');
|
||||
assert.deepEqual(zhDetails.slice(3, 6).map(item => item.sublines), [[], [], []], 'Missing, empty, and whitespace response_model values stay omitted');
|
||||
assert.deepEqual(zhDetails.slice(3, 6).map(item => item.title), [null, null, null], 'Legacy rows keep the existing native-title behavior');
|
||||
assert.equal(await page.locator('[class*="mismatch"], [class*="warning"]').count(), 0, 'Model strings do not produce an inequality warning');
|
||||
assert.ok(zhDetails.slice(3, 6).every(item => !item.title.includes('响应模型')), 'Legacy rows never invent a response model');
|
||||
assert.equal(await page.locator('.usage-model-mismatch').count(), 5, 'Only responses different from the routed model are flagged');
|
||||
assert.equal(await page.locator('.usage-td-cost strong').first().textContent(), '$0.0042');
|
||||
assert.equal(await page.locator('.usage-td-cost strong').nth(1).textContent(), '$0.00');
|
||||
assert.equal(await page.locator('.usage-td-cost').nth(2).locator('small').textContent(), '未定价');
|
||||
await assertNoHorizontalPageOverflow('desktop');
|
||||
for (const child of await modelCells().first().locator(':scope > strong, :scope > small').all()) {
|
||||
assert.equal(await child.getAttribute('title'), zhDetails[0].title, 'Hovering each line exposes the full three-model title');
|
||||
assert.equal((await child.getAttribute('title')).split('\n').slice(0, 3).join('\n'), zhDetails[0].title, 'Hovering each line exposes the full three-model title');
|
||||
}
|
||||
const longLine = await modelCells().nth(6).locator('.usage-response-model').evaluate(element => ({
|
||||
truncated: element.scrollWidth > element.clientWidth,
|
||||
@@ -131,6 +140,9 @@ function parseCsv(text) {
|
||||
const responseIndex = header.indexOf('response_model');
|
||||
assert.equal(responseIndex, aliasIndex + 1, 'response_model is immediately after alias in CSV');
|
||||
const csvById = new Map(csvRows.slice(1).map(row => [row[0], row]));
|
||||
assert.equal(csvById.get('differing')[header.indexOf('estimated_cost_usd')], '0.0042');
|
||||
assert.equal(csvById.get('matching')[header.indexOf('estimated_cost_usd')], '0');
|
||||
assert.equal(csvById.get('missing')[header.indexOf('estimated_cost_usd')], '');
|
||||
assert.equal(csvById.get('differing')[responseIndex], 'gpt-5.6-luna', 'CSV preserves a regular response model');
|
||||
assert.equal(csvById.get('matching')[responseIndex], 'gpt-6-sol', 'CSV preserves a response equal to model');
|
||||
assert.equal(csvById.get('missing')[responseIndex], '', 'CSV preserves an empty legacy response field');
|
||||
@@ -142,13 +154,13 @@ function parseCsv(text) {
|
||||
for (const [locale, labels] of Object.entries(locales)) {
|
||||
await open(locale);
|
||||
const details = await modelDetails();
|
||||
assert.equal(details[0].response, `↳ ${labels.response}: gpt-5.6-luna`, `${locale}: response line is localized`);
|
||||
assert.equal(details[0].response, `${labels.response}: gpt-5.6-luna`, `${locale}: response line is localized`);
|
||||
assert.equal(details[0].title, `${labels.request}: astra\n${labels.upstream}: gpt-6-astra\n${labels.response}: gpt-5.6-luna`, `${locale}: model title localizes all three labels`);
|
||||
}
|
||||
|
||||
for (const [theme, color] of [['light', 'rgb(194, 65, 12)'], ['dark', 'rgb(251, 146, 60)']]) {
|
||||
for (const [theme, color] of [['light', 'rgb(154, 88, 8)'], ['dark', 'rgb(240, 188, 115)']]) {
|
||||
await open('zh-CN', theme);
|
||||
assert.equal(await modelCells().first().locator('.usage-response-model').evaluate(element => getComputedStyle(element).color), color, `${theme}: response line uses the theme's readable orange`);
|
||||
assert.equal(await modelCells().first().locator('.usage-response-model').evaluate(element => getComputedStyle(element).color), color, `${theme}: response line uses the theme's mismatch text color`);
|
||||
if (process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR, `response-model-${theme}.png`) });
|
||||
|
||||
@@ -171,7 +171,7 @@ function parseCsv(text) {
|
||||
|
||||
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 0, 'The redundant request log heading is removed');
|
||||
const firstRow = page.locator('.usage-events-table tbody tr').first();
|
||||
assert.equal(await page.locator('.usage-events-table th').count(), 12, 'Saved settings cannot restore removed duplicate columns');
|
||||
assert.equal(await page.locator('.usage-events-table th').count(), 13, 'Saved settings cannot restore removed duplicate columns');
|
||||
assert.equal(await firstRow.locator('.usage-td-source svg, .usage-td-source img').count(), 0, 'Source is plain text without a logo');
|
||||
assert.equal(await firstRow.locator('.usage-td-total').getAttribute('title'), '1,200 tokens', 'The displayed total uses the recorded total instead of adding cache and reasoning again');
|
||||
assert.equal(await firstRow.locator('.tone-input').getAttribute('aria-label'), 'Input: 1000');
|
||||
@@ -291,7 +291,7 @@ function parseCsv(text) {
|
||||
|
||||
await page.locator('.usage-col-settings-btn').click();
|
||||
const checkboxes = page.locator('.usage-column-option input');
|
||||
assert.equal(await checkboxes.count(), 12, 'Column settings contain only the remaining columns');
|
||||
assert.equal(await checkboxes.count(), 13, 'Column settings contain only the remaining columns');
|
||||
for (let index = 2; index < await checkboxes.count(); index += 1) await checkboxes.nth(index).uncheck();
|
||||
await page.locator('.usage-column-dialog-actions .primary-button').click();
|
||||
await page.waitForFunction(() => document.querySelector('.usage-table-top-scrollbar').classList.contains('is-hidden'));
|
||||
@@ -345,7 +345,7 @@ function parseCsv(text) {
|
||||
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).count(), 0, 'The request log has no reset filters control');
|
||||
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('test-model');
|
||||
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 266');
|
||||
assert.equal(await page.locator('.usage-events-table tbody strong[title="test-model"]').count(), 20, 'Changing a filter resets pagination and shows matching models');
|
||||
assert.deepEqual(await page.locator('.usage-td-model > strong').allTextContents(), Array(20).fill('test-model'), 'Changing a filter resets pagination and shows matching models');
|
||||
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('test-source');
|
||||
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 133');
|
||||
assert.ok((await page.locator('.usage-td-source').allTextContents()).every(text => text === 'Test source'));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { usageModelDetails } from '../src/services/usageModel';
|
||||
|
||||
test('compares reported response with routed model, not the requested alias', () => {
|
||||
expect(usageModelDetails('gpt-routed', 'my-alias', 'gpt-routed')).toMatchObject({ showResolved: true, mismatch: false, showResponseInTooltip: false });
|
||||
expect(usageModelDetails('gpt-routed', 'my-alias', 'my-alias')).toMatchObject({ mismatch: true });
|
||||
expect(usageModelDetails('gpt-routed', '', 'different')).toMatchObject({ showResolved: false, mismatch: true });
|
||||
});
|
||||
|
||||
test('missing response and route degrade without inventing model names', () => {
|
||||
expect(usageModelDetails('gpt-routed', '', ' ')).toMatchObject({ requested: 'gpt-routed', mismatch: false });
|
||||
expect(usageModelDetails('', 'alias', 'reported')).toMatchObject({ mismatch: false, showResponseInTooltip: true });
|
||||
expect(usageModelDetails(' gpt-routed ', ' gpt-routed ', ' gpt-routed ')).toMatchObject({ showResolved: false, mismatch: false });
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { usageProviderDetails } from '../src/services/usageProvider';
|
||||
|
||||
test('usage API authentication distinguishes API routes from OAuth', () => {
|
||||
expect(usageProviderDetails('codex', 'apikey')).toMatchObject({ name: 'Codex', access: 'API' });
|
||||
expect(usageProviderDetails('codex', 'oauth')).toMatchObject({ name: 'Codex', access: 'OAuth' });
|
||||
expect(usageProviderDetails(' My gateway ', 'apikey')).toMatchObject({ name: 'My gateway', access: 'API' });
|
||||
});
|
||||
|
||||
test('missing or new API values are not guessed from model or credentials', () => {
|
||||
expect(usageProviderDetails('codex')).toMatchObject({ name: 'Codex', access: '' });
|
||||
expect(usageProviderDetails('', 'unknown')).toMatchObject({ name: '—', access: '' });
|
||||
expect(usageProviderDetails('custom', 'new-method')).toMatchObject({ name: 'custom', access: 'new-method' });
|
||||
});
|
||||
Reference in new issue
Block a user