From d60f7238dae90f3c1d275dec1c38407530b37f95 Mon Sep 17 00:00:00 2001 From: zyycn Date: Sun, 26 Jul 2026 15:17:59 +0800 Subject: [PATCH] fix: negotiate model discovery and login transition Return the Codex catalog only for client_version requests while preserving the standard OpenAI-compatible model list for other clients. Keep the login action pending until successful route loading completes. Co-authored-by: Codex --- .../crates/gateway-api/src/openai/models.rs | 38 ++++++++++++------- .../crates/gateway-api/tests/openai/models.rs | 26 +++++++++++-- frontend/src/views/login/index.vue | 24 +++++++++--- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/backend/crates/gateway-api/src/openai/models.rs b/backend/crates/gateway-api/src/openai/models.rs index ed42b0e9b..64a56641e 100644 --- a/backend/crates/gateway-api/src/openai/models.rs +++ b/backend/crates/gateway-api/src/openai/models.rs @@ -2,11 +2,12 @@ use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use gateway_core::routing::{PublicModelId, PublicModelProfile}; +use serde::Deserialize; use serde_json::{Value, json}; use crate::ApiState; @@ -19,22 +20,30 @@ use super::{ const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000; const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the user's instructions and use the available tools to complete software engineering tasks. Inspect relevant files before editing, preserve unrelated changes, and verify the result."; -/// `GET /v1/models`。 -pub(crate) async fn models(State(state): State, headers: HeaderMap) -> Response { +#[derive(Debug, Deserialize)] +pub(crate) struct ModelsQuery { + client_version: Option, +} + +/// `GET /v1/models`。Codex 携带 `client_version` 时返回其专用目录合同。 +pub(crate) async fn models( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Response { let service = state.openai(); let client = match authenticate_client(service, &headers) { Ok(client) => client, Err(error) => return authentication_error_response(error), }; - let data = service - .public_models(&client) - .into_iter() - .map(|model| openai_model_json(&model)) - .collect::>(); - let profiles = service.public_model_profiles(&client); - if !profiles.is_empty() { - let models = profiles + if query + .client_version + .as_deref() + .is_some_and(|version| !version.trim().is_empty()) + { + let models = service + .public_model_profiles(&client) .iter() .enumerate() .map(|(index, profile)| codex_model_json(profile, index)) @@ -42,14 +51,17 @@ pub(crate) async fn models(State(state): State, headers: HeaderMap) -> return ( StatusCode::OK, Json(json!({ - "object": "list", - "data": data, "models": models, })), ) .into_response(); } + let data = service + .public_models(&client) + .into_iter() + .map(|model| openai_model_json(&model)) + .collect::>(); ( StatusCode::OK, Json(json!({ diff --git a/backend/crates/gateway-api/tests/openai/models.rs b/backend/crates/gateway-api/tests/openai/models.rs index aa3ea104e..a72cbfb94 100644 --- a/backend/crates/gateway-api/tests/openai/models.rs +++ b/backend/crates/gateway-api/tests/openai/models.rs @@ -135,6 +135,12 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() { let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON"); let model = &value["models"][0]; + assert_eq!( + value + .as_object() + .map(|catalog| catalog.keys().map(String::as_str).collect::>()), + Some(vec!["models"]) + ); assert_eq!(model["slug"], "grok-4.5"); assert_eq!(model["default_reasoning_level"], "medium"); assert_eq!(model["context_window"], 500_000); @@ -163,6 +169,21 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() { ); } +#[tokio::test] +async fn models_should_keep_the_codex_contract_when_profiles_are_empty() { + let response = api_router(ModelsExecution::new()) + .await + .oneshot(authorized_request("/v1/models?client_version=0.145.0")) + .await + .expect("list empty Codex models response"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read empty Codex models body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("Codex models JSON"); + + assert_eq!(value, serde_json::json!({ "models": [] })); +} + #[tokio::test] async fn models_with_provider_profiles_should_keep_the_openai_list_contract() { let response = api_router(ModelsExecution::with_profiles()) @@ -176,10 +197,7 @@ async fn models_with_provider_profiles_should_keep_the_openai_list_contract() { let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON"); assert_eq!( - serde_json::json!({ - "object": value["object"], - "data": value["data"], - }), + value, serde_json::json!({ "object": "list", "data": [ diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 8c92ced78..daea890fa 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -17,21 +17,35 @@ const { toggleTheme } = uiStore const username = shallowRef('') const password = shallowRef('') +const loginPending = shallowRef(false) const canSubmit = computed(() => !!username.value.trim() && !!password.value.trim()) -const submitDisabled = computed(() => authStore.loading || !canSubmit.value) +const loginLoading = computed(() => authStore.loading || loginPending.value) +const submitDisabled = computed(() => loginLoading.value || !canSubmit.value) async function handleSubmit(): Promise { - if (!canSubmit.value) { + if (!canSubmit.value || loginPending.value) { return } + loginPending.value = true const success = await authStore.login({ username: username.value.trim(), password: password.value, }) - if (success) { - router.push('/') + if (!success) { + loginPending.value = false + return + } + + try { + await router.push('/') + } + finally { + // 成功时登录页会卸载;导航失败并停留当前页时才恢复按钮。 + if (router.currentRoute.value.path === '/login') { + loginPending.value = false + } } } @@ -48,7 +62,7 @@ async function handleSubmit(): Promise { v-model:username="username" v-model:password="password" :error="authStore.error" - :loading="authStore.loading" + :loading="loginLoading" :submit-disabled="submitDisabled" :effective-theme="effectiveTheme" @submit="handleSubmit"