Compare commits

...
3 Commits
Author SHA1 Message Date
ZyyandCodex a709acb149 chore: bump version to 3.3.19
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:56:10 +08:00
ZyyandCodex 466842acd8 fix(observability): search usage by API key
Resolve sk_ searches through client key references while only sending the visible key prefix from the frontend, and clarify the request detail search hint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:38:29 +08:00
ZyyandCodex b91f28f95b fix(frontend): align configuration and usage copy
Generate the displayed Codex files and CCSwitch import payload from one source, carry the full settings through deep links, and clarify the quota percentage label.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 16:37:53 +08:00
9 changed files with 147 additions and 34 deletions
@@ -88,7 +88,19 @@ pub(crate) fn push_usage_filter(
query.push(format!(" escape '\\' or {alias}.{column} like "));
query.push_bind(pattern.clone());
}
query.push(" escape '\\')");
if value.starts_with("sk_") {
query.push(format!(
" escape '\\' or exists (
select 1 from client_api_keys searched_client_key
where searched_client_key.id = {alias}.client_api_key_ref
and searched_client_key.key like "
));
query.push_bind(pattern);
query.push(" escape '\\')");
} else {
query.push(" escape '\\'");
}
query.push(")");
}
}
@@ -186,6 +186,48 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() {
database.close().await;
}
#[tokio::test]
async fn usage_search_should_match_client_api_key_prefix() {
let Some(database) = TestDatabase::create("usage_client_api_key_search").await else {
return;
};
let now = Utc::now();
seed_observability_facts(&database.pool, now)
.await
.expect("seed observability facts");
let plaintext_key = format!("sk_{}", "K".repeat(43));
sqlx::query(
"insert into client_api_keys (id, name, key, enabled, created_at, updated_at)
values ('key_observe', 'usage search', $1, true, $2, $2)",
)
.bind(&plaintext_key)
.bind(now)
.execute(&database.pool)
.await
.expect("seed searchable client API key");
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("observability range");
for search in [plaintext_key.as_str(), &plaintext_key[..10]] {
let page = observability_repository(&database.pool)
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
..UsageRecordFilter::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("search usage by client API key");
assert_eq!(page.total, 1);
assert_eq!(page.items[0].id, "req_observe_success");
}
database.close().await;
}
#[tokio::test]
async fn ops_search_should_treat_sql_wildcards_as_literals() {
let Some(database) = TestDatabase::create("ops_literal_wildcard_search").await else {
@@ -50,7 +50,7 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
class="flex shrink-0 items-baseline gap-1 text-[9px] font-emphasis text-cp-text-quaternary"
title="最近使用额度的当前已用比例"
>
<span>消耗比例</span>
<span>使用率</span>
<strong class="font-mono font-heavy tabular-nums" :class="currentUsageTextClass">
{{ currentUsageDisplay }}
</strong>
@@ -8,6 +8,10 @@ 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'
import {
buildCodexConfigFiles,
CODEX_WEBSOCKET_ENABLED_BY_DEFAULT,
} from '../utils/codexConfig'
const props = defineProps<{
apiKey: {
@@ -24,7 +28,7 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ default: false })
const activePlatform = shallowRef('unix')
const websocketEnabled = shallowRef(true)
const websocketEnabled = shallowRef(CODEX_WEBSOCKET_ENABLED_BY_DEFAULT)
const platformOptions = [
{ label: 'macOS / Linux', value: 'unix', icon: Apple },
@@ -40,31 +44,15 @@ const configPath = computed(() =>
const authPath = computed(() =>
activePlatform.value === 'windows' ? '%userprofile%\\.codex\\auth.json' : '~/.codex/auth.json',
)
const codexAuthJson = computed(() => JSON.stringify({ OPENAI_API_KEY: keyValue.value }, null, 2))
const defaultModel = 'gpt-5.6-terra'
const codexConfigToml = computed(
() => `model_provider = "OpenAI"
model = "${defaultModel}"
review_model = "${defaultModel}"
model_reasoning_effort = "max"
service_tier = "default"
disable_response_storage = true
network_access = "enabled"
[model_providers.OpenAI]
name = "OpenAI"
base_url = "${props.apiBaseUrl}"
wire_api = "responses"${websocketEnabled.value ? '\nsupports_websockets = true' : ''}
requires_openai_auth = true
[features]${websocketEnabled.value ? '\nresponses_websockets_v2 = true' : ''}
goals = true`,
)
const codexConfigFiles = computed(() => buildCodexConfigFiles({
apiKey: keyValue.value,
baseUrl: props.apiBaseUrl,
websocketEnabled: websocketEnabled.value,
}))
const visibleFiles = computed(() => [
{ path: configPath.value, content: codexConfigToml.value, scrollbarHeight: '360px' },
{ path: authPath.value, content: codexAuthJson.value, scrollbarHeight: undefined },
{ path: configPath.value, content: codexConfigFiles.value.configToml, scrollbarHeight: '360px' },
{ path: authPath.value, content: codexConfigFiles.value.authJson, scrollbarHeight: undefined },
])
</script>
@@ -1,4 +1,8 @@
const DEFAULT_CC_SWITCH_MODEL = 'gpt-5.5'
import {
buildCodexConfigFiles,
CODEX_DEFAULT_MODEL,
CODEX_WEBSOCKET_ENABLED_BY_DEFAULT,
} from './codexConfig'
export interface CodexCcSwitchImportInput {
apiKey: string
@@ -7,18 +11,37 @@ export interface CodexCcSwitchImportInput {
}
export function buildCodexCcSwitchImportDeeplink(input: CodexCcSwitchImportInput): string {
const baseUrl = input.baseUrl.replace(/\/+$/, '')
const configFiles = buildCodexConfigFiles({
apiKey: input.apiKey,
baseUrl: input.baseUrl,
websocketEnabled: CODEX_WEBSOCKET_ENABLED_BY_DEFAULT,
})
// Carry the complete files shown in the UI; the explicit fields below keep
// compatibility with CCSwitch versions that only extract common settings.
const config = encodeBase64(JSON.stringify({
auth: configFiles.auth,
config: configFiles.configToml,
}))
const entries: [string, string][] = [
['resource', 'provider'],
['app', 'codex'],
['model', DEFAULT_CC_SWITCH_MODEL],
['model', CODEX_DEFAULT_MODEL],
['name', input.providerName],
['homepage', baseUrl],
['endpoint', baseUrl],
['homepage', configFiles.baseUrl],
['endpoint', configFiles.baseUrl],
['apiKey', input.apiKey],
['configFormat', 'json'],
['config', config],
['usageEnabled', 'false'],
]
return `ccswitch://v1/import?${new URLSearchParams(entries).toString()}`
}
function encodeBase64(value: string) {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes)
binary += String.fromCharCode(byte)
return btoa(binary)
}
@@ -0,0 +1,37 @@
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-terra'
export const CODEX_WEBSOCKET_ENABLED_BY_DEFAULT = true
export interface CodexConfigInput {
apiKey: string
baseUrl: string
websocketEnabled?: boolean
}
export function buildCodexConfigFiles(input: CodexConfigInput) {
const baseUrl = input.baseUrl.replace(/\/+$/, '')
const websocketEnabled = input.websocketEnabled ?? CODEX_WEBSOCKET_ENABLED_BY_DEFAULT
const auth = { OPENAI_API_KEY: input.apiKey }
const configToml = `model_provider = "OpenAI"
model = "${CODEX_DEFAULT_MODEL}"
review_model = "${CODEX_DEFAULT_MODEL}"
model_reasoning_effort = "max"
service_tier = "default"
disable_response_storage = true
network_access = "enabled"
[model_providers.OpenAI]
name = "OpenAI"
base_url = "${baseUrl}"
wire_api = "responses"${websocketEnabled ? '\nsupports_websockets = true' : ''}
requires_openai_auth = true
[features]${websocketEnabled ? '\nresponses_websockets_v2 = true' : ''}
goals = true`
return {
auth,
authJson: JSON.stringify(auth, null, 2),
baseUrl,
configToml,
}
}
@@ -19,7 +19,7 @@ const search = defineModel<string>('search', { required: true })
<template>
<div class="flex w-full items-center gap-3" role="group" aria-label="使用记录筛选与操作">
<div class="min-w-0 flex-1 sm:w-96 sm:flex-none">
<BaseInput v-model="search" placeholder="请求、Key、账号或模型 ID 前缀" class="w-full">
<BaseInput v-model="search" placeholder="请求、API Key、账号或模型 ID 前缀" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
@@ -21,6 +21,8 @@ interface UseUsageRecordsTableOptions {
type UsageLoadScope = 'all' | 'table'
const CLIENT_API_KEY_VISIBLE_PREFIX_LENGTH = 10
interface UsageLoadOptions {
scope?: UsageLoadScope
background?: boolean
@@ -49,7 +51,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
...(providerQuery.value ? { provider: providerQuery.value } : {}),
})
const filterParams = () => ({
search: searchQuery.value || undefined,
search: usageSearchParam(searchQuery.value),
})
const usagePagination = computed(() => ({
currentPage: currentPage.value,
@@ -244,6 +246,15 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
}
}
function usageSearchParam(value: string) {
const search = value.trim()
if (!search)
return undefined
if (search.startsWith('sk_'))
return search.slice(0, CLIENT_API_KEY_VISIBLE_PREFIX_LENGTH)
return search
}
function emptySummary() {
const summary: Awaited<ReturnType<typeof getUsageRecordSummary>> = {
totalRequests: '0',
+1 -1
View File
@@ -1 +1 @@
version: "3.3.18"
version: "3.3.19"