Compare commits

...
5 Commits
Author SHA1 Message Date
ZyyandCodex ef37ae8d9e chore: bump version to 3.2.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 23:31:04 +08:00
ZyyandCodex fc13d4f2b6 fix(frontend): restore responsive component behavior
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 20:38:48 +08:00
ZyyandCodex 78f9c0c800 chore: bump version to 3.2.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:52 +08:00
ZyyandCodex a3bc7bc445 fix(frontend): increase account table row height
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:02 +08:00
ZyyandCodex 0132114285 fix(openai): let upstream validate catalog models
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:45:06 +08:00
14 changed files with 99 additions and 74 deletions
+1 -3
View File
@@ -1368,9 +1368,7 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
use CodexCredentialCatalogError as Error;
provider_admin_error(match error {
Error::InvalidCredentialData | Error::ConflictingModelFacts | Error::InvalidEtag => {
ProviderAdminErrorKind::Invalid
}
Error::InvalidCredentialData | Error::InvalidEtag => ProviderAdminErrorKind::Invalid,
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict,
Error::Upstream { .. } | Error::Cache => ProviderAdminErrorKind::Unavailable,
@@ -138,8 +138,6 @@ pub enum CodexCredentialCatalogError {
InvalidCredentialData,
#[error("Codex model catalog upstream query failed: {detail}")]
Upstream { detail: String },
#[error("Codex model catalog contains conflicting account facts")]
ConflictingModelFacts,
#[error("Codex model catalog cache is unavailable")]
Cache,
#[error("Codex model catalog ETag is invalid")]
@@ -359,11 +357,6 @@ impl CodexCredentialCatalogService {
union_order.push(entry.key().clone());
entry.insert(model.clone());
}
// 展示与元数据允许跨套餐漂移,union 取首见值;只有路由
// 事实冲突才让整轮刷新失败。
Entry::Occupied(entry) if conflicting_routing_facts(entry.get(), model) => {
return Err(CodexCredentialCatalogError::ConflictingModelFacts);
}
Entry::Occupied(_) => {}
}
}
@@ -661,13 +654,6 @@ fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
.collect()
}
/// 路由正确性只依赖 Responses API 支持证据(决定 Generate operation 是否
/// 编译进 `ModelCapabilities`);display_name/limits/metadata 等展示事实
/// 跨套餐漂移不构成冲突。
fn conflicting_routing_facts(existing: &CodexCatalogModel, candidate: &CodexCatalogModel) -> bool {
existing.capabilities().responses_api() != candidate.capabilities().responses_api()
}
fn catalog_candidates_by_scope(
accounts: Vec<ProviderAccount>,
) -> Result<BTreeMap<CodexCatalogScope, Vec<ProviderAccount>>, CodexCredentialCatalogError> {
@@ -1492,12 +1492,10 @@ fn nonnegative_millis(value: Option<i64>) -> Option<u64> {
}
fn compile_model_capabilities(model: &CodexCatalogModel) -> ProviderModelCapabilities {
let evidence = model.capabilities();
let mut operations = BTreeSet::new();
if evidence.responses_api() == CodexCatalogCapabilityEvidence::DeclaredNative {
operations.insert(OperationKind::Generate);
}
let capabilities = ModelCapabilities::new(operations, None).with_upstream_feature_validation();
// Catalog membership is enough to publish Generate. `supported_in_api` is advisory;
// the upstream response is authoritative for normal and diagnostic requests.
let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None)
.with_upstream_feature_validation();
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
.with_presentation(codex_model_presentation(model))
}
@@ -253,7 +253,7 @@ async fn cross_plan_presentation_drift_unions_on_the_first_seen_model() {
}
#[tokio::test]
async fn cross_plan_responses_api_conflict_still_fails_the_union() {
async fn cross_plan_supported_in_api_drift_unions_on_the_first_seen_model() {
let store = Arc::new(MemoryAccountStore::default());
seed_account_with_plan(&store, "acct_conflict_plus", "plus").await;
seed_account_with_plan(&store, "acct_conflict_pro", "pro").await;
@@ -272,15 +272,12 @@ async fn cross_plan_responses_api_conflict_still_fails_the_union() {
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let error = service
let snapshot = service
.synchronize()
.await
.expect_err("routing facts must still agree across plans");
.expect("supported_in_api drift must not block catalog publication");
assert!(matches!(
error,
CodexCredentialCatalogError::ConflictingModelFacts
));
assert_eq!(snapshot.models().len(), 1);
server.verify().await;
}
@@ -18,7 +18,10 @@ use gateway_core::engine::{
};
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::GatewayEvent;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
use gateway_core::operation::{
CapabilityRequirements, GenerateRequest, Operation, OperationKind, ProtocolPayload,
ProviderSessionState,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
@@ -2424,3 +2427,36 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
assert!(presentation.verbosity());
assert!(!presentation.hidden());
}
#[tokio::test]
async fn provider_routes_catalog_model_when_supported_in_api_is_false() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_non_api_model").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_raw(
br#"{"models":[{"slug":"gpt-5.6-sol-wm","display_name":"GPT-5.6-Sol-WM","supported_in_api":false,"visibility":"hide"}]}"#,
"application/json",
),
)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
let capabilities = provider
.query_model_capabilities()
.await
.expect("catalog model capabilities");
let model = capabilities.first().expect("catalog model");
assert!(
model
.capabilities()
.match_requirements(&CapabilityRequirements::new(OperationKind::Generate))
.is_some()
);
}
@@ -75,7 +75,11 @@ const classes = computed(() => [
:aria-busy="loading || undefined"
:title="label"
>
<span v-if="loading" class="inline-grid place-items-center" aria-hidden="true">
<span
v-if="loading"
class="inline-grid place-items-center [&>svg]:block [&>svg]:origin-center [&>svg]:transform-view [&>svg]:will-change-transform"
aria-hidden="true"
>
<slot name="loading">
<LoaderCircle
class="animate-spin motion-reduce:animate-none"
@@ -14,7 +14,7 @@ export type TableColumnKind
| 'actions'
| 'custom'
export type TableColumnSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
export type TableColumnSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
export type TableColumnAlign = 'left' | 'right' | 'center'
type TableColumnSticky = 'left' | 'right'
@@ -65,6 +65,7 @@ const columnWidths: Record<TableColumnSize, number> = {
'xl': 184,
'2xl': 240,
'3xl': 288,
'4xl': 352,
}
const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
-1
View File
@@ -1,5 +1,4 @@
html {
min-width: 360px;
background: var(--cp-bg-page);
color: var(--cp-text-primary);
font-family: var(--font-sans);
@@ -1,10 +1,26 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { modelSuccessRateTextClass } from '../constants'
defineProps<{
account: AccountRow
}>()
type AccountModelUsage = AccountRow['usage']['models'][number]
const modelUsageColumns = defineTableColumns<AccountModelUsage>([
{ key: 'model', label: '模型', kind: 'text', size: 'lg' },
{ key: 'requestCountDisplay', label: '调用', kind: 'numeric', size: 'xs' },
{ key: 'successRateDisplay', label: '成功率', kind: 'numeric', size: 'sm' },
{ key: 'inputTokensDisplay', label: '输入', kind: 'numeric', size: 'xs' },
{ key: 'outputTokensDisplay', label: '输出', kind: 'numeric', size: 'xs' },
{ key: 'cachedTokensDisplay', label: '缓存', kind: 'numeric', size: 'xs' },
{ key: 'totalTokensDisplay', label: '总计', kind: 'numeric', size: 'xs' },
{ key: 'billingAmountUsdDisplay', label: '计费', kind: 'numeric', size: 'sm' },
{ key: 'lastUsedAtDisplay', label: '最近请求', kind: 'datetime', size: 'sm' },
])
</script>
<template>
@@ -62,44 +78,21 @@ defineProps<{
<span class="text-[11px] font-emphasis text-cp-muted-text">当前额度窗口</span>
</div>
<div
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pb-2 text-[11px] font-heavy text-cp-secondary shadow-[inset_0_-1px_0_var(--cp-divider-subtle)]"
>
<span>模型</span>
<span>调用</span>
<span>成功率</span>
<span>输入</span>
<span>输出</span>
<span>缓存</span>
<span>总计</span>
<span>计费</span>
<span>最近请求</span>
</div>
<div
v-if="account.usage.models.length === 0"
class="pt-3 text-[12px] font-emphasis text-cp-muted-text"
>
-
</div>
<template v-else>
<div
v-for="model in account.usage.models"
:key="model.model"
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pt-3 text-[12px] font-emphasis text-cp-primary"
<div class="h-52 min-w-0">
<BaseTable
:columns="modelUsageColumns"
:rows="account.usage.models"
row-key="model"
density="compact"
empty-text="暂无模型用量"
>
<span class="truncate">{{ model.model }}</span>
<span>{{ model.requestCountDisplay }}</span>
<span :class="modelSuccessRateTextClass(model.successRate)">
{{ model.successRateDisplay }}
</span>
<span>{{ model.inputTokensDisplay }}</span>
<span>{{ model.outputTokensDisplay }}</span>
<span>{{ model.cachedTokensDisplay }}</span>
<span>{{ model.totalTokensDisplay }}</span>
<span>{{ model.billingAmountUsdDisplay }}</span>
<span>{{ model.lastUsedAtDisplay }}</span>
</div>
</template>
<template #successRateDisplay="{ row }">
<span :class="modelSuccessRateTextClass(row.successRate)">
{{ row.successRateDisplay }}
</span>
</template>
</BaseTable>
</div>
</div>
</section>
</template>
+1 -1
View File
@@ -190,7 +190,7 @@ const {
<template #body>
<div class="flex h-full min-h-0 flex-col">
<BaseTable
class="min-h-0 flex-1"
class="min-h-0 flex-1 [--cp-table-row-min-height:72px]"
:columns="accountColumns"
:rows="accounts"
:loading="loading"
@@ -157,6 +157,7 @@ function togglePasswordVisible(): void {
<div class="min-w-0 mb-2">
<BaseButton
variant="primary"
size="lg"
type="submit"
class="login-submit"
@@ -179,6 +180,11 @@ function togglePasswordVisible(): void {
--cp-text-primary: var(--login-title);
--cp-text-secondary: var(--login-description);
--cp-text-muted: var(--login-placeholder);
--cp-accent: var(--login-button);
--cp-accent-hover: var(--login-button-hover);
--cp-accent-pressed: var(--login-button-active);
--cp-accent-border: var(--login-input-icon);
--cp-accent-on: var(--cp-white);
--cp-info: var(--login-button);
--cp-info-hover: var(--login-button-hover);
--cp-info-pressed: var(--login-button-active);
@@ -10,6 +10,7 @@ import {
usageIsCompact,
usageRecordType,
usageRecordTypeClass,
usageUserAgent,
} from '../utils/records'
import UsageBillingCell from './UsageBillingCell.vue'
import UsageClientIpCell from './UsageClientIpCell.vue'
@@ -60,6 +61,12 @@ withDefaults(
<UsageClientIpCell :record="row" />
</template>
<template #userAgent="{ row }">
<span class="block max-w-full wrap-break-word whitespace-normal font-mono text-[12px] leading-[1.4] font-emphasis text-cp-secondary">
{{ usageUserAgent(row) }}
</span>
</template>
<template #model="{ row }">
<UsageModelCell :record="row" />
</template>
+1 -1
View File
@@ -28,7 +28,7 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'lg' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '2xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'text', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])
+1 -1
View File
@@ -1 +1 @@
version: "3.2.6"
version: "3.2.8"