fix(observability): restore WebSocket usage records

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-30 22:40:16 +08:00
co-authored by Codex
parent 832e2e8bdd
commit a268f5ca66
14 changed files with 216 additions and 65 deletions
@@ -250,10 +250,6 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
.upstream_model_id
.clone()
.or_else(|| record.requested_model_id.clone());
let transport = record
.upstream_transport
.clone()
.or_else(|| Some(record.client_transport.clone()));
let metadata = provider_metadata_fields(record.provider_metadata_json.as_deref());
UsageRecordView {
id: record.id.clone(),
@@ -274,7 +270,8 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
upstream_model: record.upstream_model_id,
service_tier: record.service_tier,
status_code,
transport,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
protocol: record.protocol.clone(),
http_version: record.http_version.clone(),
client_status_code: record.client_status_code.map(i64::from),
@@ -349,6 +346,8 @@ pub(crate) fn provider_metadata_fields(value: Option<&str>) -> BTreeMap<String,
"requestKind",
"subagentKind",
"transport",
"clientTransport",
"upstreamTransport",
"httpVersion",
"clientStatusCode",
"upstreamStatusCode",
@@ -99,7 +99,8 @@ pub struct UsageRecordView {
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub transport: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
pub protocol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_version: Option<String>,
@@ -835,7 +835,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
protocol: "openai".to_owned(),
operation: "generate".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
client_transport: "websocket".to_owned(),
requested_model_id: Some("grok-4.5".to_owned()),
provider_kind: Some("xai".to_owned()),
provider_account_ref: Some("acct_snapshot".to_owned()),
@@ -981,7 +981,8 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
"requestKind": value["data"]["items"][0]["requestKind"],
"subagentKind": value["data"]["items"][0]["subagentKind"],
"compact": value["data"]["items"][0]["compact"],
"transport": value["data"]["items"][0]["transport"],
"clientTransport": value["data"]["items"][0]["clientTransport"],
"upstreamTransport": value["data"]["items"][0]["upstreamTransport"],
"transportDecisionWaitMs": value["data"]["items"][0]["latencyDetails"]["transportDecisionWaitMs"],
"wsConnectMs": value["data"]["items"][0]["latencyDetails"]["wsConnectMs"],
"firstReasoningMs": value["data"]["items"][0]["latencyDetails"]["firstReasoningMs"],
@@ -1014,7 +1015,8 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
"requestKind": "review",
"subagentKind": "worker",
"compact": true,
"transport": "http_sse",
"clientTransport": "websocket",
"upstreamTransport": "http_sse",
"transportDecisionWaitMs": 7,
"wsConnectMs": 11,
"firstReasoningMs": 19,
@@ -92,9 +92,11 @@ pub(crate) fn push_usage_filter(
/// `model_requests` 同时承担执行审计:包括上游已发送、但尚未收到首个事件就断开的
/// WebSocket 请求。这些失败仍必须留给 Ops Errors 和调度健康度分析,不能混入用量、
/// 成本、账号使用次数或请求明细。
/// 客户端 WebSocket 的每个 `response.create` 没有独立 HTTP 状态码;成功终态与下游
/// 提交边界已足以证明交付,连接握手的 101 不能冒充单次请求状态。
pub(crate) fn completed_usage_fact_predicate(alias: &str) -> String {
format!(
"{alias}.outcome = 'succeeded' and {alias}.downstream_committed_at is not null and {alias}.client_status_code between 200 and 399"
"{alias}.outcome = 'succeeded' and {alias}.downstream_committed_at is not null and (({alias}.client_transport = 'websocket' and {alias}.client_status_code is null) or {alias}.client_status_code between 200 and 399)"
)
}
@@ -9,6 +9,62 @@ use sqlx::PgPool;
use super::TestDatabase;
#[tokio::test]
async fn usage_facts_should_accept_statusless_websocket_but_reject_statusless_http() {
let Some(database) = TestDatabase::create("snapshot_statusless_websocket_usage").await else {
return;
};
let started_at = Utc::now();
seed_account(
&database.pool,
"acct_statusless_transport",
"Statusless transport",
None,
"oauth",
started_at,
)
.await;
let store = PgExecutionStore::new(database.pool.clone());
let mut websocket_request = new_request("req_statusless_websocket", started_at);
websocket_request.client_transport = "websocket".to_owned();
store
.insert_model_request_with_first_attempt(
websocket_request,
attempt("req_statusless_websocket", 1, "acct_statusless_transport"),
)
.await
.expect("insert statusless WebSocket request");
store
.insert_model_request_with_first_attempt(
new_request("req_statusless_http", started_at),
attempt("req_statusless_http", 1, "acct_statusless_transport"),
)
.await
.expect("insert statusless HTTP request");
finalize_request_without_client_status(&database.pool, "req_statusless_websocket", started_at)
.await;
finalize_request_without_client_status(&database.pool, "req_statusless_http", started_at).await;
let repository = PgObservabilityRepository::new(database.pool.clone(), None);
let page = repository
.list_usage_records(usage_query(started_at, UsageRecordFilter::default()))
.await
.expect("list statusless transport usage records");
let request_ids = page
.items
.iter()
.map(|record| record.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
(page.total, request_ids),
(1, vec!["req_statusless_websocket"])
);
database.close().await;
}
#[tokio::test]
async fn request_snapshots_should_survive_account_deletion() {
let Some(database) = TestDatabase::create("snapshot_request_delete").await else {
@@ -767,6 +823,25 @@ async fn finalize_request(pool: &PgPool, id: &str, started_at: DateTime<Utc>) {
.expect("finalize request");
}
async fn finalize_request_without_client_status(
pool: &PgPool,
id: &str,
started_at: DateTime<Utc>,
) {
sqlx::query(
"update model_requests
set outcome = 'succeeded', upstream_send_state = 'sent',
client_status_code = null, upstream_status_code = 200,
downstream_committed_at = $2, completed_at = $2
where id = $1",
)
.bind(id)
.bind(started_at + chrono::Duration::seconds(5))
.execute(pool)
.await
.expect("finalize request without client status");
}
async fn seed_api_key(pool: &PgPool, id: &str, name: &str, now: DateTime<Utc>) {
sqlx::query(
"insert into client_api_keys (id, name, key, enabled, created_at, updated_at)
+2 -1
View File
@@ -89,7 +89,8 @@ export interface UsageRecord {
upstreamModel: string | null
serviceTier: string | null
statusCode: number | null
transport: string | null
clientTransport: string
upstreamTransport: string | null
protocol: string
httpVersion: string | null
clientStatusCode: number | null
+49 -9
View File
@@ -6,22 +6,55 @@ const props = withDefaults(
label: string
disabled?: boolean
showLabel?: boolean
activeText?: string
inactiveText?: string
inlinePrompt?: boolean
width?: string | number
}>(),
{
disabled: false,
showLabel: false,
activeText: '',
inactiveText: '',
inlinePrompt: false,
width: '',
},
)
const model = defineModel<boolean>({ default: false })
const hasStateText = computed(() => Boolean(props.activeText || props.inactiveText))
const inlineText = computed(() => model.value ? props.activeText : props.inactiveText)
const trackStyle = computed(() => props.width === ''
? undefined
: {
width: typeof props.width === 'number' || /^\d+(?:\.\d+)?$/.test(props.width)
? `${props.width}px`
: props.width,
})
const trackClasses = computed(() => [
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
'relative inline-flex h-6 min-w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
props.disabled
? 'bg-cp-bg-container-disabled shadow-none'
: model.value
? 'bg-cp-primary shadow-cp-tertiary group-hover:bg-cp-primary-hover'
: 'bg-cp-fill-tertiary shadow-cp-tertiary group-hover:bg-cp-bg-text-active',
? 'bg-cp-primary shadow-cp-tertiary'
: 'bg-cp-fill-tertiary shadow-cp-tertiary',
])
const thumbClasses = computed(() => [
'absolute top-0.5 z-10 size-5 rounded-full bg-cp-white shadow-cp-tertiary transition-[left] duration-180 ease-out motion-reduce:transition-none',
model.value ? 'left-[calc(100%-1.375rem)]' : 'left-0.5',
])
const inlineTextClasses = computed(() => [
'pointer-events-none absolute inset-0 flex min-w-0 select-none items-center justify-center overflow-hidden text-[10px] leading-none font-heavy text-cp-white transition-[padding] duration-180 motion-reduce:transition-none',
model.value ? 'pr-6 pl-1' : 'pr-1 pl-6',
])
const inactiveTextClasses = computed(() => [
'text-cp leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none',
model.value ? 'text-cp-text-secondary' : 'text-cp-primary-text',
])
const activeTextClasses = computed(() => [
'text-cp leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none',
model.value ? 'text-cp-primary-text' : 'text-cp-text-secondary',
])
</script>
@@ -29,9 +62,12 @@ const trackClasses = computed(() => [
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex items-center gap-2.5"
class="relative inline-flex items-center gap-2.5"
:class="disabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'"
>
<span v-if="inactiveText && !inlinePrompt" :class="inactiveTextClasses">
{{ inactiveText }}
</span>
<input
v-model="model"
type="checkbox"
@@ -42,13 +78,17 @@ const trackClasses = computed(() => [
>
<span
:class="trackClasses"
:style="trackStyle"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
>
<span
class="size-5 rounded-full bg-cp-bg-container shadow-cp-tertiary transition-transform duration-180 ease-out motion-reduce:transition-none"
:class="model ? 'translate-x-5' : 'translate-x-0'"
/>
<span v-if="inlinePrompt && inlineText" :class="inlineTextClasses">
<span class="min-w-0 truncate">{{ inlineText }}</span>
</span>
<span :class="thumbClasses" />
</span>
<span v-if="activeText && !inlinePrompt" :class="activeTextClasses">{{ activeText }}</span>
<span v-else-if="showLabel && !hasStateText" class="text-cp font-emphasis text-cp-text">
{{ label }}
</span>
<span v-if="showLabel" class="text-cp font-emphasis text-cp-text">{{ label }}</span>
</label>
</template>
@@ -7,6 +7,7 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
const props = defineProps<{
apiKey: {
@@ -23,6 +24,7 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ default: false })
const activePlatform = shallowRef('unix')
const websocketEnabled = shallowRef(true)
const platformOptions = [
{ label: 'macOS / Linux', value: 'unix', icon: Apple },
@@ -49,21 +51,20 @@ model_reasoning_effort = "max"
service_tier = "default"
disable_response_storage = true
network_access = "enabled"
windows_wsl_setup_acknowledged = true
[model_providers.OpenAI]
name = "OpenAI"
base_url = "${props.apiBaseUrl}"
wire_api = "responses"
wire_api = "responses"${websocketEnabled.value ? '\nsupports_websockets = true' : ''}
requires_openai_auth = true
[features]
[features]${websocketEnabled.value ? '\nresponses_websockets_v2 = true' : ''}
goals = true`,
)
const visibleFiles = computed(() => [
{ path: configPath.value, content: codexConfigToml.value },
{ path: authPath.value, content: codexAuthJson.value },
{ path: configPath.value, content: codexConfigToml.value, scrollbarHeight: '360px' },
{ path: authPath.value, content: codexAuthJson.value, scrollbarHeight: undefined },
])
</script>
@@ -75,8 +76,16 @@ const visibleFiles = computed(() => [
size="lg"
>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented v-model="activePlatform" label="配置平台" :options="platformOptions" />
<BaseSwitch
v-model="websocketEnabled"
label="切换 WebSocket 配置"
active-text="WS"
inactive-text="WS"
inline-prompt
:width="56"
/>
</div>
<div class="flex flex-col gap-3">
@@ -101,6 +110,7 @@ const visibleFiles = computed(() => [
</BaseIconButton>
</div>
<BaseScrollbar
:height="file.scrollbarHeight"
max-height="360px"
>
<div class="mx-3 mb-3 rounded-cp bg-cp-bg-container px-3.5 py-3 shadow-cp-tertiary">
+19 -17
View File
@@ -11,7 +11,8 @@ interface PreviewUsageRecordOptions {
provider: 'openai' | 'xai'
authenticationKind: 'oauth' | 'api_key'
model: string
transport: 'http_sse' | 'websocket' | 'http'
clientTransport: 'http_sse' | 'websocket' | 'http_json'
upstreamTransport: 'http_sse' | 'websocket'
reasoningEffort: string
inputTokens: number
outputTokens: number
@@ -106,7 +107,7 @@ function previewHealthStatus(index: number): DashboardHealthStatus {
function previewUsageRecord(options: PreviewUsageRecordOptions): UsageRecord {
const totalTokens = options.inputTokens + options.outputTokens
const reasoningTokens = Math.round(options.outputTokens * 0.32)
const stream = options.transport !== 'http'
const stream = options.clientTransport !== 'http_json'
return {
id: options.id,
@@ -124,12 +125,13 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageRecord {
upstreamModel: options.model,
serviceTier: options.provider === 'openai' ? 'priority' : 'standard',
statusCode: 200,
transport: options.transport,
clientTransport: options.clientTransport,
upstreamTransport: options.upstreamTransport,
protocol: stream ? 'responses' : 'chat_completions',
httpVersion: options.transport === 'websocket' ? null : 'HTTP/2',
clientStatusCode: 200,
upstreamStatusCode: 200,
websocketPool: options.transport === 'websocket' ? { kind: 'warm' } : null,
httpVersion: options.upstreamTransport === 'websocket' ? null : 'HTTP/2',
clientStatusCode: options.clientTransport === 'websocket' ? null : 200,
upstreamStatusCode: options.upstreamTransport === 'websocket' ? null : 200,
websocketPool: options.upstreamTransport === 'websocket' ? { kind: 'warm' } : null,
imageGenerationRequested: false,
imageGenerationSucceeded: null,
latencyDetails: {
@@ -364,16 +366,16 @@ export const themeDashboardSummary: DashboardSummaryResponse = {
},
],
usageRecords: [
previewUsageRecord({ id: 'preview-01', accountEmail: 'relay@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.6-codex', transport: 'http_sse', reasoningEffort: 'high', inputTokens: 18_420, outputTokens: 3_860, cachedTokens: 8_192, firstTokenMs: 486, latencyMs: 3_280, estimatedCost: '$0.0618', createdAt: '2026-08-23T14:42:16+08:00', createdAtDisplay: '14:42:16', clientIp: '10.24.8.16', userAgent: 'codex-cli/0.34.0' }),
previewUsageRecord({ id: 'preview-02', accountEmail: 'gateway@example.com', provider: 'xai', authenticationKind: 'oauth', model: 'grok-code-fast-1', transport: 'websocket', reasoningEffort: 'medium', inputTokens: 12_760, outputTokens: 2_140, cachedTokens: 4_096, firstTokenMs: 318, latencyMs: 2_460, estimatedCost: '$0.0286', createdAt: '2026-08-23T14:39:52+08:00', createdAtDisplay: '14:39:52', clientIp: '10.24.8.21', userAgent: 'grok-cli/0.7.3' }),
previewUsageRecord({ id: 'preview-03', accountEmail: 'control@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.5', transport: 'http_sse', reasoningEffort: 'xhigh', inputTokens: 32_840, outputTokens: 6_720, cachedTokens: 16_384, firstTokenMs: 572, latencyMs: 5_840, estimatedCost: '$0.1042', createdAt: '2026-08-23T14:35:08+08:00', createdAtDisplay: '14:35:08', clientIp: '10.24.9.11', userAgent: 'codex-desktop/26.818.41705', compact: true }),
previewUsageRecord({ id: 'preview-04', accountEmail: 'batch@example.com', provider: 'openai', authenticationKind: 'api_key', model: 'gpt-5.2', transport: 'http', reasoningEffort: 'low', inputTokens: 4_860, outputTokens: 920, cachedTokens: 0, firstTokenMs: 264, latencyMs: 1_180, estimatedCost: '$0.0124', createdAt: '2026-08-23T14:31:44+08:00', createdAtDisplay: '14:31:44', clientIp: '10.24.12.9', userAgent: 'openai-node/6.4.0' }),
previewUsageRecord({ id: 'preview-05', accountEmail: 'analysis@example.com', provider: 'xai', authenticationKind: 'api_key', model: 'grok-4.20', transport: 'http_sse', reasoningEffort: 'high', inputTokens: 21_640, outputTokens: 4_380, cachedTokens: 7_168, firstTokenMs: 426, latencyMs: 4_120, estimatedCost: '$0.0731', createdAt: '2026-08-23T14:28:19+08:00', createdAtDisplay: '14:28:19', clientIp: '10.24.15.32', userAgent: 'curl/8.14.1' }),
previewUsageRecord({ id: 'preview-06', accountEmail: 'scheduler@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.6-codex', transport: 'websocket', reasoningEffort: 'medium', inputTokens: 9_760, outputTokens: 1_840, cachedTokens: 3_072, firstTokenMs: 296, latencyMs: 2_020, estimatedCost: '$0.0248', createdAt: '2026-08-23T14:24:03+08:00', createdAtDisplay: '14:24:03', clientIp: '10.24.4.18', userAgent: 'codex-cli/0.34.0' }),
previewUsageRecord({ id: 'preview-07', accountEmail: 'review@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.5', transport: 'http_sse', reasoningEffort: 'high', inputTokens: 15_280, outputTokens: 3_120, cachedTokens: 6_144, firstTokenMs: 448, latencyMs: 3_740, estimatedCost: '$0.0527', createdAt: '2026-08-23T14:18:37+08:00', createdAtDisplay: '14:18:37', clientIp: '10.24.18.7', userAgent: 'codex-desktop/26.818.41705' }),
previewUsageRecord({ id: 'preview-08', accountEmail: 'fallback@example.com', provider: 'xai', authenticationKind: 'oauth', model: 'grok-code-fast-1', transport: 'websocket', reasoningEffort: 'medium', inputTokens: 7_940, outputTokens: 1_460, cachedTokens: 2_048, firstTokenMs: 342, latencyMs: 1_920, estimatedCost: '$0.0194', createdAt: '2026-08-23T14:13:25+08:00', createdAtDisplay: '14:13:25', clientIp: '10.24.6.23', userAgent: 'grok-cli/0.7.3' }),
previewUsageRecord({ id: 'preview-09', accountEmail: 'desktop@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.6-codex', transport: 'http_sse', reasoningEffort: 'xhigh', inputTokens: 28_420, outputTokens: 5_940, cachedTokens: 12_288, firstTokenMs: 618, latencyMs: 5_260, estimatedCost: '$0.0925', createdAt: '2026-08-23T14:07:11+08:00', createdAtDisplay: '14:07:11', clientIp: '10.24.7.40', userAgent: 'codex-desktop/26.818.41705', compact: true }),
previewUsageRecord({ id: 'preview-10', accountEmail: 'api@example.com', provider: 'openai', authenticationKind: 'api_key', model: 'gpt-5.2', transport: 'http', reasoningEffort: 'low', inputTokens: 3_620, outputTokens: 680, cachedTokens: 0, firstTokenMs: 238, latencyMs: 980, estimatedCost: '$0.0091', createdAt: '2026-08-23T13:58:46+08:00', createdAtDisplay: '13:58:46', clientIp: '10.24.3.14', userAgent: 'openai-python/1.99.0' }),
previewUsageRecord({ id: 'preview-01', accountEmail: 'relay@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.6-codex', clientTransport: 'http_sse', upstreamTransport: 'websocket', reasoningEffort: 'high', inputTokens: 18_420, outputTokens: 3_860, cachedTokens: 8_192, firstTokenMs: 486, latencyMs: 3_280, estimatedCost: '$0.0618', createdAt: '2026-08-23T14:42:16+08:00', createdAtDisplay: '14:42:16', clientIp: '10.24.8.16', userAgent: 'codex-cli/0.34.0' }),
previewUsageRecord({ id: 'preview-02', accountEmail: 'gateway@example.com', provider: 'xai', authenticationKind: 'oauth', model: 'grok-code-fast-1', clientTransport: 'websocket', upstreamTransport: 'http_sse', reasoningEffort: 'medium', inputTokens: 12_760, outputTokens: 2_140, cachedTokens: 4_096, firstTokenMs: 318, latencyMs: 2_460, estimatedCost: '$0.0286', createdAt: '2026-08-23T14:39:52+08:00', createdAtDisplay: '14:39:52', clientIp: '10.24.8.21', userAgent: 'grok-cli/0.7.3' }),
previewUsageRecord({ id: 'preview-03', accountEmail: 'control@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.5', clientTransport: 'http_sse', upstreamTransport: 'websocket', reasoningEffort: 'xhigh', inputTokens: 32_840, outputTokens: 6_720, cachedTokens: 16_384, firstTokenMs: 572, latencyMs: 5_840, estimatedCost: '$0.1042', createdAt: '2026-08-23T14:35:08+08:00', createdAtDisplay: '14:35:08', clientIp: '10.24.9.11', userAgent: 'codex-desktop/26.818.41705', compact: true }),
previewUsageRecord({ id: 'preview-04', accountEmail: 'batch@example.com', provider: 'openai', authenticationKind: 'api_key', model: 'gpt-5.2', clientTransport: 'http_json', upstreamTransport: 'http_sse', reasoningEffort: 'low', inputTokens: 4_860, outputTokens: 920, cachedTokens: 0, firstTokenMs: 264, latencyMs: 1_180, estimatedCost: '$0.0124', createdAt: '2026-08-23T14:31:44+08:00', createdAtDisplay: '14:31:44', clientIp: '10.24.12.9', userAgent: 'openai-node/6.4.0' }),
previewUsageRecord({ id: 'preview-05', accountEmail: 'analysis@example.com', provider: 'xai', authenticationKind: 'api_key', model: 'grok-4.20', clientTransport: 'http_sse', upstreamTransport: 'http_sse', reasoningEffort: 'high', inputTokens: 21_640, outputTokens: 4_380, cachedTokens: 7_168, firstTokenMs: 426, latencyMs: 4_120, estimatedCost: '$0.0731', createdAt: '2026-08-23T14:28:19+08:00', createdAtDisplay: '14:28:19', clientIp: '10.24.15.32', userAgent: 'curl/8.14.1' }),
previewUsageRecord({ id: 'preview-06', accountEmail: 'scheduler@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.6-codex', clientTransport: 'websocket', upstreamTransport: 'websocket', reasoningEffort: 'medium', inputTokens: 9_760, outputTokens: 1_840, cachedTokens: 3_072, firstTokenMs: 296, latencyMs: 2_020, estimatedCost: '$0.0248', createdAt: '2026-08-23T14:24:03+08:00', createdAtDisplay: '14:24:03', clientIp: '10.24.4.18', userAgent: 'codex-cli/0.34.0' }),
previewUsageRecord({ id: 'preview-07', accountEmail: 'review@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.5', clientTransport: 'http_sse', upstreamTransport: 'websocket', reasoningEffort: 'high', inputTokens: 15_280, outputTokens: 3_120, cachedTokens: 6_144, firstTokenMs: 448, latencyMs: 3_740, estimatedCost: '$0.0527', createdAt: '2026-08-23T14:18:37+08:00', createdAtDisplay: '14:18:37', clientIp: '10.24.18.7', userAgent: 'codex-desktop/26.818.41705' }),
previewUsageRecord({ id: 'preview-08', accountEmail: 'fallback@example.com', provider: 'xai', authenticationKind: 'oauth', model: 'grok-code-fast-1', clientTransport: 'websocket', upstreamTransport: 'http_sse', reasoningEffort: 'medium', inputTokens: 7_940, outputTokens: 1_460, cachedTokens: 2_048, firstTokenMs: 342, latencyMs: 1_920, estimatedCost: '$0.0194', createdAt: '2026-08-23T14:13:25+08:00', createdAtDisplay: '14:13:25', clientIp: '10.24.6.23', userAgent: 'grok-cli/0.7.3' }),
previewUsageRecord({ id: 'preview-09', accountEmail: 'desktop@example.com', provider: 'openai', authenticationKind: 'oauth', model: 'gpt-5.6-codex', clientTransport: 'http_sse', upstreamTransport: 'websocket', reasoningEffort: 'xhigh', inputTokens: 28_420, outputTokens: 5_940, cachedTokens: 12_288, firstTokenMs: 618, latencyMs: 5_260, estimatedCost: '$0.0925', createdAt: '2026-08-23T14:07:11+08:00', createdAtDisplay: '14:07:11', clientIp: '10.24.7.40', userAgent: 'codex-desktop/26.818.41705', compact: true }),
previewUsageRecord({ id: 'preview-10', accountEmail: 'api@example.com', provider: 'openai', authenticationKind: 'api_key', model: 'gpt-5.2', clientTransport: 'http_json', upstreamTransport: 'http_sse', reasoningEffort: 'low', inputTokens: 3_620, outputTokens: 680, cachedTokens: 0, firstTokenMs: 238, latencyMs: 980, estimatedCost: '$0.0091', createdAt: '2026-08-23T13:58:46+08:00', createdAtDisplay: '13:58:46', clientIp: '10.24.3.14', userAgent: 'openai-python/1.99.0' }),
],
poolSummary: {
total: 52,
@@ -20,8 +20,8 @@ import {
usageLatencyDetails,
usageModelDisplay,
usageReasoningEffort,
usageRecordType,
usageTokenDetails,
usageTransportType,
usageUserAgent,
visibleRequestText,
visibleResponseText,
@@ -58,7 +58,8 @@ const finalAttemptIndex = computed(() => {
})
const overviewItems = computed(() => [
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '类型', value: props.record ? usageRecordType(props.record) : '—' },
{ label: '客户端传输', value: usageTransportType(props.record?.clientTransport) },
{ label: '上游传输', value: usageTransportType(props.record?.upstreamTransport) },
{ label: '耗时', value: props.record?.latencyMsDisplay, mono: true },
{
label: latencyDetails.value?.firstOutputLabel ?? '首字',
@@ -99,7 +100,6 @@ const detailGroups = computed(() => [
mono: true,
wrap: true,
},
{ label: '传输方式', value: props.record?.transport, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '尝试序号', value: finalAttemptIndex.value },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
@@ -128,7 +128,7 @@ const attemptColumns = defineTableColumns<AttemptRow>([
{ key: 'outcome', label: '结果', kind: 'status', size: 'sm' },
{ key: 'provider', label: '平台', kind: 'meta', size: 'sm' },
{ key: 'model', label: '模型', kind: 'mono', size: 'lg' },
{ key: 'transport', label: '传输', kind: 'status', size: 'sm' },
{ key: 'transport', label: '上游传输', kind: 'status', size: 'md' },
{ key: 'statusCode', label: '状态', kind: 'status', size: 'sm' },
{ key: 'accountLabel', label: '账号', kind: 'mono', size: '3xl' },
{ key: 'latencyMs', label: '耗时', kind: 'numeric', size: 'sm' },
@@ -8,8 +8,6 @@ import {
usageAccountText,
usageAuthenticationKind,
usageIsCompact,
usageRecordType,
usageRecordTypeClass,
usageUserAgent,
} from '../utils/records'
import UsageBillingCell from './UsageBillingCell.vue'
@@ -18,6 +16,7 @@ import UsageLatencyCell from './UsageLatencyCell.vue'
import UsageModelCell from './UsageModelCell.vue'
import UsageReasoningEffortCell from './UsageReasoningEffortCell.vue'
import UsageTokenCell from './UsageTokenCell.vue'
import UsageTransportBadge from './UsageTransportBadge.vue'
// 使用记录表只负责该领域的单元格呈现;筛选与分页由页面组合。
withDefaults(
@@ -89,13 +88,12 @@ withDefaults(
</div>
</template>
<template #recordType="{ row }">
<span
class="inline-flex h-6 min-w-12 items-center justify-center rounded-full px-2 text-cp leading-none font-bold"
:class="usageRecordTypeClass(row)"
>
{{ usageRecordType(row) }}
</span>
<template #clientTransport="{ row }">
<UsageTransportBadge :transport="row.clientTransport" />
</template>
<template #upstreamTransport="{ row }">
<UsageTransportBadge :transport="row.upstreamTransport" />
</template>
<template #tokenDetails="{ row }">
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { usageTransportType, usageTransportTypeClass } from '../utils/records'
defineProps<{
transport?: string | null
}>()
</script>
<template>
<span
class="inline-flex h-6 min-w-12 items-center justify-center rounded-full px-2 text-cp leading-none font-bold"
:class="usageTransportTypeClass(transport)"
>
{{ usageTransportType(transport) }}
</span>
</template>
+2 -1
View File
@@ -22,7 +22,8 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'recordType', label: '类型', kind: 'status', size: 'sm' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'lg' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
+12 -8
View File
@@ -33,7 +33,8 @@ export interface UsageViewModel {
upstreamModel: string | null
serviceTier: string | null
statusCode: number | null
transport: string | null
clientTransport: string
upstreamTransport: string | null
attemptIndex: number | null
attemptCount: number
responseId: string | null
@@ -99,7 +100,8 @@ export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): U
upstreamModel: record.upstreamModel,
serviceTier: record.serviceTier,
statusCode: record.statusCode,
transport: record.transport,
clientTransport: record.clientTransport,
upstreamTransport: record.upstreamTransport,
attemptIndex: record.attemptIndex,
attemptCount: record.attemptCount,
responseId: record.responseId,
@@ -148,17 +150,19 @@ export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): U
export type UsageDisplayRecord = UsageViewModel
export function usageRecordType(record: UsageDisplayRecord) {
if (record.transport === 'websocket')
export function usageTransportType(transport?: string | null) {
if (transport === 'websocket')
return 'WS'
if (record.transport === 'http_sse')
if (transport === 'http_sse')
return 'SSE'
return 'HTTP'
if (transport === 'http' || transport === 'http_json')
return 'HTTP'
return transport || '—'
}
export function usageRecordTypeClass(record: UsageDisplayRecord) {
const type = usageRecordType(record)
export function usageTransportTypeClass(transport?: string | null) {
const type = usageTransportType(transport)
if (type === 'WS')
return 'bg-cp-blue-bg text-cp-blue-text-on-bg'
if (type === 'SSE')