mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(observability): restore WebSocket usage records
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user