mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat(key-usage): add about dialog with build information
Reuse the shared About dialog from a toolbar icon and expose version and commit identifiers through an authenticated key-only endpoint. Align the About and refresh button backgrounds with the configuration button in dark mode. Validate the response field allowlist and session boundaries, and document the new endpoint. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -370,10 +370,12 @@ pub async fn initialize(
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let system = Arc::new(DefaultSystemService::new(system));
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
system.clone(),
|
||||
));
|
||||
let openai = Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
@@ -423,7 +425,7 @@ pub async fn initialize(
|
||||
registry,
|
||||
pricing_source,
|
||||
)),
|
||||
system: Arc::new(DefaultSystemService::new(system)),
|
||||
system,
|
||||
openai,
|
||||
xai,
|
||||
import_tasks,
|
||||
|
||||
@@ -6,7 +6,7 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService,
|
||||
AuthService, SystemService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::SessionSubject,
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
},
|
||||
system::SystemVersion,
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
};
|
||||
@@ -27,6 +28,8 @@ use super::{map_store_error, observability::health_timeline_at};
|
||||
|
||||
#[async_trait]
|
||||
pub trait KeyUsageService: Send + Sync {
|
||||
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
|
||||
|
||||
async fn config(&self, session_id: Option<&str>)
|
||||
-> Result<Option<ClientKeySecret>, AdminError>;
|
||||
|
||||
@@ -47,6 +50,7 @@ pub(crate) struct DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
}
|
||||
|
||||
impl DefaultKeyUsageService {
|
||||
@@ -54,11 +58,13 @@ impl DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
keys,
|
||||
observations,
|
||||
system,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +95,13 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
|
||||
#[async_trait]
|
||||
impl KeyUsageService for DefaultKeyUsageService {
|
||||
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError> {
|
||||
if self.key_id(session_id).await?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.system.version().await.map(Some)
|
||||
}
|
||||
|
||||
async fn config(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
|
||||
@@ -23,6 +23,7 @@ where
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/key-usage/version", get(version::<S>))
|
||||
.route("/api/key-usage/config", get(config::<S>))
|
||||
.route("/api/key-usage/overview", get(overview::<S>))
|
||||
.route("/api/key-usage/records", get(records::<S>))
|
||||
@@ -32,10 +33,31 @@ where
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
async fn version<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(_): AdminQuery<query::EmptyQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let version = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.version(session_cookie::value(&headers).as_deref())
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::version(version)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn config<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(_): AdminQuery<query::ConfigQuery>,
|
||||
AdminQuery(_): AdminQuery<query::EmptyQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
|
||||
@@ -7,6 +7,7 @@ use gateway_admin::model::{
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
|
||||
},
|
||||
system::SystemVersion,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -15,6 +16,21 @@ use crate::admin::observability::{
|
||||
health_timeline_view, usage_list_token_details,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct VersionView {
|
||||
version: String,
|
||||
git_sha: String,
|
||||
}
|
||||
|
||||
pub(super) fn version(version: SystemVersion) -> VersionView {
|
||||
// 密钥用户仅查看构建标识,不暴露部署环境、更新状态或内部诊断。
|
||||
VersionView {
|
||||
version: version.version,
|
||||
git_sha: version.git_sha,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ConfigView {
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::admin::AdminError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct ConfigQuery {}
|
||||
pub(super) struct EmptyQuery {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
|
||||
@@ -40,8 +40,8 @@ async fn login(app: &Router, mode: &str) -> String {
|
||||
}
|
||||
|
||||
async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response {
|
||||
let path = if resource == "config" {
|
||||
format!("/api/key-usage/config{suffix}")
|
||||
let path = if matches!(resource, "config" | "version") {
|
||||
format!("/api/key-usage/{resource}{suffix}")
|
||||
} else {
|
||||
format!("/api/key-usage/{resource}?{RANGE}{suffix}")
|
||||
};
|
||||
@@ -54,6 +54,33 @@ async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Respon
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_exposes_only_build_identifiers_for_key_sessions() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
let response = get(&app, "version", "", &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response_json(response).await["data"],
|
||||
json!({"version": "3.7.0", "gitSha": "internal-revision"})
|
||||
);
|
||||
assert_eq!(
|
||||
get(&app, "version", "?keyId=other", &cookie).await.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(cookie_request(
|
||||
Method::GET,
|
||||
"/api/admin/system/version",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_reveals_only_the_session_keys_name_and_plaintext() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
@@ -288,7 +315,7 @@ async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() {
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let admin = login(&app, "admin").await;
|
||||
let key = login(&app, "key").await;
|
||||
for resource in ["overview", "records", "config"] {
|
||||
for resource in ["overview", "records", "config", "version"] {
|
||||
assert_eq!(
|
||||
get(&app, resource, "", "").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
@@ -427,6 +454,11 @@ async fn namespace_errors_remain_json_and_uncacheable() {
|
||||
"/api/key-usage/config",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
(
|
||||
Method::POST,
|
||||
"/api/key-usage/version",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
|
||||
@@ -348,6 +348,7 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 `
|
||||
| `GET` | `/api/key-usage/overview` | `startTime`、`endTime`、`model?` | 用量汇总、趋势、当前额度和北京时间今日健康时间线 |
|
||||
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 |
|
||||
| `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 |
|
||||
| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 |
|
||||
|
||||
用量查询的起止时间使用 RFC3339,开始必须早于结束,一次最多 31 天。模型按完整名称匹配;
|
||||
不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条;
|
||||
@@ -369,6 +370,8 @@ Token 明细、费用明细、用时/首字与状态。Token 和费用复用现
|
||||
成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码,
|
||||
缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容。
|
||||
|
||||
version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断。
|
||||
|
||||
config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 Key,不接受任何查询参数。
|
||||
使用统计页在打开“密钥配置”弹窗时读取,用于复制 Codex 配置文件或导入 CCSwitch;
|
||||
明文不进入用量轮询响应或浏览器持久化存储,关闭弹窗后清除页面中的配置状态。
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface KeyUsageConfig {
|
||||
plaintextKey: string
|
||||
}
|
||||
|
||||
export interface KeyUsageVersion {
|
||||
version: string
|
||||
gitSha: string
|
||||
}
|
||||
|
||||
export interface KeyUsageMetrics {
|
||||
requests: number
|
||||
inputTokens: number
|
||||
@@ -91,6 +96,14 @@ export function getKeyUsageOverview(params: KeyUsageQuery, options: RequestOptio
|
||||
})
|
||||
}
|
||||
|
||||
export function getKeyUsageVersion(options: RequestOptions = {}) {
|
||||
return request<KeyUsageVersion>({
|
||||
url: '/api/key-usage/version',
|
||||
method: 'GET',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function getKeyUsageConfig(options: RequestOptions = {}) {
|
||||
return request<KeyUsageConfig>({
|
||||
url: '/api/key-usage/config',
|
||||
|
||||
+6
-6
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Github } from '@boxicons/vue'
|
||||
import { ExternalLink } from '@lucide/vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
|
||||
const props = defineProps<{
|
||||
version: { version: string, gitSha: string } | null
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
const { version } = storeToRefs(useSystemUpdateStore())
|
||||
|
||||
const author = 'Zyy'
|
||||
const githubUrl = 'https://github.com/zyycn/codex-proxy-rs'
|
||||
|
||||
@@ -25,11 +25,11 @@ function normalizeBuildValue(value: string | undefined) {
|
||||
}
|
||||
|
||||
const versionDisplay = computed(() => {
|
||||
const versionText = normalizeBuildValue(version.value?.version).replace(/^v/i, '')
|
||||
const versionText = normalizeBuildValue(props.version?.version).replace(/^v/i, '')
|
||||
return versionText ? `v${versionText}` : ''
|
||||
})
|
||||
const gitShaDisplay = computed(() => {
|
||||
const gitSha = normalizeBuildValue(version.value?.gitSha)
|
||||
const gitSha = normalizeBuildValue(props.version?.gitSha)
|
||||
return gitSha ? gitSha.slice(0, 8) : ''
|
||||
})
|
||||
|
||||
@@ -3,11 +3,11 @@ import { storeToRefs } from 'pinia'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
|
||||
import AppAboutModal from '@/components/AppAboutModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
import AppAboutModal from './components/AppAboutModal.vue'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
|
||||
import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
|
||||
@@ -15,7 +15,7 @@ import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
|
||||
const uiStore = useUiStore()
|
||||
const systemUpdateStore = useSystemUpdateStore()
|
||||
const { sidebarCollapsed } = storeToRefs(uiStore)
|
||||
const { loadedOnce } = storeToRefs(systemUpdateStore)
|
||||
const { loadedOnce, version } = storeToRefs(systemUpdateStore)
|
||||
const { toggleSidebar } = uiStore
|
||||
const route = useRoute()
|
||||
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
|
||||
@@ -110,7 +110,7 @@ watch(
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<AppAboutModal v-model="aboutOpen" />
|
||||
<AppAboutModal v-model="aboutOpen" :version="version" />
|
||||
<SystemUpdateModal v-model="systemUpdateOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, LogOut, Moon, RefreshCw, Sun, Terminal } from '@lucide/vue'
|
||||
import { Info, KeyRound, LogOut, Moon, RefreshCw, Sun, Terminal } from '@lucide/vue'
|
||||
import { shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
@@ -10,7 +10,7 @@ import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useThemeStore } from '@/stores/modules/theme'
|
||||
|
||||
defineProps<{ name?: string, prefix?: string, refreshing: boolean, configuring: boolean }>()
|
||||
defineEmits<{ refresh: [], configure: [] }>()
|
||||
defineEmits<{ refresh: [], configure: [], openAbout: [] }>()
|
||||
const period = defineModel<string>('period', { required: true })
|
||||
const refreshInterval = defineModel<string>('refreshInterval', { required: true })
|
||||
const auth = useAuthStore()
|
||||
@@ -45,13 +45,16 @@ async function logout() {
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex max-w-[calc(100vw-32px)] flex-wrap items-center justify-end gap-2">
|
||||
<BaseIconButton label="关于" variant="filled" @click="$emit('openAbout')">
|
||||
<Info class="size-4" />
|
||||
</BaseIconButton>
|
||||
<BaseButton variant="secondary" :loading="configuring" @click="$emit('configure')">
|
||||
<Terminal class="size-4" />
|
||||
密钥配置
|
||||
</BaseButton>
|
||||
<BaseSelect v-model="period" aria-label="统计时间范围" class="w-29" :options="[{ label: '今天', value: 'today' }, { label: '近 7 天', value: '7d' }, { label: '近 30 天', value: '30d' }]" />
|
||||
<BaseSelect v-model="refreshInterval" aria-label="自动刷新频率" class="w-30" :options="[{ label: '30 秒刷新', value: '30' }, { label: '60 秒刷新', value: '60' }, { label: '暂停刷新', value: '0' }]" />
|
||||
<BaseIconButton label="刷新用量" variant="secondary" :loading="refreshing" @click="$emit('refresh')">
|
||||
<BaseIconButton label="刷新用量" variant="filled" :loading="refreshing" @click="$emit('refresh')">
|
||||
<RefreshCw class="size-4" />
|
||||
</BaseIconButton>
|
||||
<BaseIconButton label="切换主题" @click="theme.toggleTheme($event)">
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { KeyUsageVersion } from '@/api/modules/key-usage'
|
||||
import { Search } from '@lucide/vue'
|
||||
import { shallowRef } from 'vue'
|
||||
import { getKeyUsageVersion } from '@/api/modules/key-usage'
|
||||
import ApiKeyConfigModal from '@/components/ApiKeyConfigModal.vue'
|
||||
import AppAboutModal from '@/components/AppAboutModal.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import RequestHealthTimelineCard from '@/views/dashboard/components/RequestHealthTimelineCard.vue'
|
||||
@@ -17,6 +21,26 @@ import { keyUsageTime } from './utils/format'
|
||||
const { period, model, kind, refreshInterval, overview, overviewLoading, overviewError, refreshing, recordsStale, records, refresh, changePageSize, changePage } = useKeyUsage()
|
||||
const { items, currentPage, pageSize, total, loading: recordsLoading, error: recordsError } = records
|
||||
const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig } = useKeyConfig()
|
||||
const aboutOpen = shallowRef(false)
|
||||
const version = shallowRef<KeyUsageVersion | null>(null)
|
||||
const versionLoading = shallowRef(false)
|
||||
|
||||
async function openAbout() {
|
||||
aboutOpen.value = true
|
||||
if (version.value || versionLoading.value)
|
||||
return
|
||||
|
||||
versionLoading.value = true
|
||||
try {
|
||||
version.value = await getKeyUsageVersion({ silent: true })
|
||||
}
|
||||
catch {
|
||||
// 版本不可用不影响查看项目信息,下次打开时重试。
|
||||
}
|
||||
finally {
|
||||
versionLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,7 +48,7 @@ const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig }
|
||||
<BaseScrollbar>
|
||||
<div class="mx-auto flex min-h-full w-full max-w-480 flex-col gap-5 p-4 min-[961px]:p-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" />
|
||||
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" @open-about="openAbout" />
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span v-if="overview" class="text-cp-sm text-cp-text-tertiary">更新于 {{ keyUsageTime(overview.asOf).slice(11) }}</span>
|
||||
<BaseInput v-model="model" class="ml-auto w-60 max-w-full" placeholder="输入完整模型名称" aria-label="筛选统计和日志的模型" :maxlength="128">
|
||||
@@ -50,5 +74,6 @@ const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig }
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
<ApiKeyConfigModal v-model="showConfig" title="密钥配置" :api-key="configKey" :api-base-url="apiBaseUrl" @copy="copyConfig" />
|
||||
<AppAboutModal v-model="aboutOpen" :version="version" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user