mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a709acb149 | ||
|
|
466842acd8 | ||
|
|
b91f28f95b |
@@ -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 @@
|
||||
version: "3.3.18"
|
||||
version: "3.3.19"
|
||||
|
||||
Reference in New Issue
Block a user