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 <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-26 15:17:59 +08:00
co-authored by Codex
parent ea684dceeb
commit d60f7238da
3 changed files with 66 additions and 22 deletions
+25 -13
View File
@@ -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<ApiState>, headers: HeaderMap) -> Response {
#[derive(Debug, Deserialize)]
pub(crate) struct ModelsQuery {
client_version: Option<String>,
}
/// `GET /v1/models`。Codex 携带 `client_version` 时返回其专用目录合同。
pub(crate) async fn models(
State(state): State<ApiState>,
Query(query): Query<ModelsQuery>,
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::<Vec<_>>();
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<ApiState>, 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::<Vec<_>>();
(
StatusCode::OK,
Json(json!({
@@ -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::<Vec<_>>()),
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": [
+19 -5
View File
@@ -17,21 +17,35 @@ const { toggleTheme } = uiStore
const username = shallowRef('')
const password = shallowRef('')
const loginPending = shallowRef(false)
const canSubmit = computed<boolean>(() => !!username.value.trim() && !!password.value.trim())
const submitDisabled = computed<boolean>(() => authStore.loading || !canSubmit.value)
const loginLoading = computed<boolean>(() => authStore.loading || loginPending.value)
const submitDisabled = computed<boolean>(() => loginLoading.value || !canSubmit.value)
async function handleSubmit(): Promise<void> {
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
}
}
}
</script>
@@ -48,7 +62,7 @@ async function handleSubmit(): Promise<void> {
v-model:username="username"
v-model:password="password"
:error="authStore.error"
:loading="authStore.loading"
:loading="loginLoading"
:submit-disabled="submitDisabled"
:effective-theme="effectiveTheme"
@submit="handleSubmit"