Prhub

#25293 [SMG] Add /v1/models fallback for model name discovery

原始 PR 作者 Gruner-atero 合并时间 2026-05-18 22:02 文件变更 4 提交数 3 评论 4 代码增减 +233 / -0

执行摘要

SMG 添加 /v1/models 模型名称发现回退

当后端不实现/server_info时,SMG路由器无法发现模型名称,回退到UNKNOWN_MODEL_ID,破坏多模型路由。许多兼容OpenAI的推理引擎实现了/v1/models但没有实现/server_info。

值得精读,特别是fallback模式的实现和测试设计。核心函数get_model_name_from_v1_models的JSON字段验证是良好的API适配实践。如果使用SMG,建议关注此变更,并考虑扩展类似回退逻辑。

讨论亮点

Review中仅有一条实质性评论:@alexnails 建议从/v1/models解析模型名称时验证object字段必须为model,以确保符合OpenAI API规范,避免从其他资源类型中误取ID。作者已采纳并修复,评论标记为已解决。其余评论为空或同意。

实现拆解

按步骤:

  1. 新增fallback函数:在discover_metadata.rs中添加get_model_name_from_v1_models,向/v1/models发送GET请求,从返回JSON的data数组中筛选第一个objectmodel的条目,提取其id字段作为模型名称。
  2. 集成到DiscoverMetadataStep:在HTTP路径的label收集阶段,如果通过/server_info/model_info均未获得模型名称(即labels中不包含model_pathserved_model_name),则调用上述fallback函数,将结果以served_model_name键存入labels。
  3. 测试模拟worker:在mock_worker.rs中新增OpenAiOnlyMockWorker结构体,仅路由/health/health_generate/v1/models端点,模拟未实现/server_info的后端。
  4. 集成测试:新增worker_discovery_test.rs,包含两个测试用例:test_model_name_discovered_via_server_info验证正常路径(使用完整MockWorker),test_model_name_discovered_via_v1_models_fallback验证回退路径(使用OpenAiOnlyMockWorker)。
  5. 注册测试模块:在mod.rs中添加pub mod worker_discovery_test;
文件 模块 状态 重要度
sgl-model-gateway/src/core/steps/worker/local/discover_metadata.rs 发现步骤 modified 7.47
sgl-model-gateway/tests/common/mock_worker.rs 测试模拟器 modified 7.1
sgl-model-gateway/tests/routing/worker_discovery_test.rs 集成测试 added 6.89
sgl-model-gateway/tests/routing/mod.rs 路由模块 modified 3.32

关键符号

get_model_name_from_v1_models OpenAiOnlyMockWorker::new OpenAiOnlyMockWorker::start OpenAiOnlyMockWorker::stop

关键源码片段

sgl-model-gateway/src/core/steps/worker/local/discover_metadata.rs data-contract

核心变更文件,新增 fallback 函数并集成到步骤中。

/// Get model name from /v1/models endpoint (OpenAI-compatible fallback).
/// 当 `/server_info` 和 `/model_info` 均失败时调用此函数。
async fn get_model_name_from_v1_models(url: &str, api_key: Option<&str>) -> Result<String, String> {
    let base_url = url.trim_end_matches('/');
    let models_url = format!("{}/v1/models", base_url);    let mut req = HTTP_CLIENT.get(&models_url);
    if let Some(key) = api_key {
        req = req.bearer_auth(key);
    }    let response = req
        .send()
        .await
        .map_err(|e| format!("Failed to connect to {}: {}", models_url, e))?;    if !response.status().is_success() {
        return Err(format!(
            "Server returned status {} from {}",
            response.status(),
            models_url
        ));
    }    let json: Value = response
        .json()
        .await
        .map_err(|e| format!("Failed to parse response from {}: {}", models_url, e))?;    // 根据 OpenAI API 规范,只取 object 为 "model" 的第一个条目
    json["data"]
        .as_array()
        .and_then(|arr| {
            arr.iter()
                .find(|entry| entry["object"].as_str() == Some("model"))
        })
        .and_then(|entry| entry["id"].as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| format!("No model found in response from {}", models_url))
}// 在 DiscoverMetadataStep 的 HTTP 分支中调用:
// 如果已有 labels 不含 model_path 或 served_model_name,则尝试 fallback
if !labels.contains_key("model_path") && !labels.contains_key("served_model_name") {
    if let Ok(model_name) =
        get_model_name_from_v1_models(&config.url, config.api_key.as_deref()).await
    {
        labels.insert("served_model_name".to_string(), model_name);
    }
}
sgl-model-gateway/tests/common/mock_worker.rs test-coverage

新增 OpenAiOnlyMockWorker 用于测试回退路径。

/// A minimal OpenAI-compatible mock worker that does not implement /server_info or /model_info.
pub struct OpenAiOnlyMockWorker {
    port: u16,
    model_name: String,
    shutdown_handle: Option<tokio::task::JoinHandle<()>>,
    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}impl OpenAiOnlyMockWorker {
    pub fn new(model_name: impl Into<String>) -> Self {
        Self { port: 0, model_name: model_name.into(), shutdown_handle: None, shutdown_tx: None }
    }    pub async fn start(&mut self) -> Result<String, Box<dyn std::error::Error>> {
        // 绑定随机端口,仅注册 /health, /health_generate, /v1/models
        // /v1/models 返回包含 model_name 的简化 OpenAI 响应
        // 详细实现见完整文件
        Ok(format!("http://127.0.0.1:{}", self.port))
    }    pub async fn stop(&mut self) {
        if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); }
        if let Some(h) = self.shutdown_handle.take() {
            let _ = tokio::time::timeout(tokio::time::Duration::from_secs(5), h).await;
        }
    }
}impl Drop for OpenAiOnlyMockWorker {
    fn drop(&mut self) {
        if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); }
    }
}
sgl-model-gateway/tests/routing/worker_discovery_test.rs test-coverage

新增集成测试文件,覆盖正常和回退两种场景。

use smg::{config::RouterConfig, core::Job};
use crate::common::{create_test_context, mock_worker::{HealthStatus, MockWorkerConfig, OpenAiOnlyMockWorker, WorkerType}, AppTestContext};#[cfg(test)]
mod worker_discovery_tests {
    use super::*;    #[tokio::test]
    async fn test_model_name_discovered_via_server_info() {
        let ctx = AppTestContext::new(vec![MockWorkerConfig {
            port: 0, worker_type: WorkerType::Regular,
            health_status: HealthStatus::Healthy, response_delay_ms: 0, fail_rate: 0.0,
        }]).await;
        let discovered = ctx.app_context.worker_registry.get_models();
        assert!(discovered.contains(&"mock-model-path".to_string()), "Expected mock-model-path");
        ctx.shutdown().await;
    }    #[tokio::test]
    async fn test_model_name_discovered_via_v1_models_fallback() {
        let mut worker = OpenAiOnlyMockWorker::new("my-model");
        let url = worker.start().await.unwrap();
        let config = RouterConfig::builder().regular_mode(vec![url.clone()]).random_policy()
            .host("127.0.0.1").port(0).max_payload_size(256 * 1024 * 1024)
            .request_timeout_secs(600).worker_startup_timeout_secs(5)
            .worker_startup_check_interval_secs(1).max_concurrent_requests(64)
            .queue_timeout_secs(60).build_unchecked();
        let app_context = create_test_context(config.clone()).await;
        app_context.worker_job_queue.get().unwrap()
            .submit(Job::InitializeWorkersFromConfig { router_config: Box::new(config) }).await.unwrap();
        // 等待 worker 健康
        let start = tokio::time::Instant::now();
        loop {
            if app_context.worker_registry.get_all().iter().any(|w| w.is_healthy()) { break; }
            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        }
        let discovered = app_context.worker_registry.get_models();
        assert!(discovered.contains(&"my-model".to_string()), "Fallback failed");
        worker.stop().await;
    }
}

评论区精华

验证从 /v1/models 提取的模型对象类型 正确性

alexnails 要求确保只取 object 为 model 的条目,避免误取其他资源。

结论:作者添加了 .find(|entry| entry["object"].as_str() == Some("model")) 过滤。 · 已解决

风险与影响

整体风险较低。回退逻辑仅在/server_info/model_info均失败时触发,不影响正常流量路径。主要风险来自部分引擎的/v1/models响应格式可能不完全符合OpenAI标准(如不包含objectid字段),但错误处理已设计为返回Err字符串,不会导致崩溃。建议用户自测与目标后端的兼容性。

影响范围局限在SMG的model gateway组件,使更多只实现/v1/models点的后端(如vLLM、TGI等)能被自动发现模型名称,提升多模型路由的兼容性。对已有正常实现的引擎无影响。开发团队需维持测试用例以覆盖未来变更。

兼容性风险

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论