Skip to content

LM-Studio本地Provider

沿 LM Studio 的本地准备流程分析模型列表、lms 子进程、同步等待与后台预载,解释准备返回、模型加载和首次 Responses 请求之间的时序边界。

基于rust-v0.150.0
CodexRustLM StudioProvider

LM-Studio本地Provider ​

Codex 使用 LM Studio 时,启动准备已经返回,第一条消息却仍然等待模型加载,这是否说明预载没有生效?回答之前,要先确定代码究竟等待了什么:HTTP 探测、模型名称查询、外部 lms 下载进程,还是后台发出的最小 Responses 请求。

本文面向了解 Rust 的 Result、借用与 async/await 的读者,重点解释本地准备流程中的 HTTP、子进程和异步任务边界。OSS 模式与 Provider 选择的共同入口可先看 Ollama 本地 Provider,正式模型请求的构造见模型请求构造。这里不会把 LM Studio 服务端的 GPU 调度、模型驻留或自动加载策略当成 Codex 已经实现的功能。

沿本文读完,应能从 --oss --local-provider lmstudio 找到实际请求和子进程参数,判断每个失败发生在哪个阶段,并解释为什么 ensure_oss_ready 返回 Ok(()) 之后仍然可能出现加载错误。阅读中最关键的区分是:调用已提交、外部进程已退出和推理已经完成分别由不同消费者判断。

1. 准备契约 ​

1.1 四个返回点 ​

codex-lmstudio 对外导出 LMStudioClient、DEFAULT_OSS_MODEL 与 ensure_oss_ready。库入口在 codex-rs/lmstudio/src/lib.rs,HTTP 与下载实现位于 client.rs;这两份文件构成本文的主线。它们向 Core 借用配置,但没有定义一个持久化的模型状态机。

观察到的结果实际判断依据尚不能确定的事情
check_server 成功GET <base>/models 得到成功 HTTP 状态JSON 合法、模型存在、Responses 可用
fetch_models 命中名称data[].id 中存在精确字符串权重已加载或具有工具调用能力
download_model 成功lms get --yes <model> 进程成功退出HTTP 服务已刷新列表、模型已驻留
ensure_oss_ready 成功必要的同步步骤通过,并已提交后台预载 task后台请求已完成、首次推理无需等待

这张表中的每一项都对应真实返回语句。尤其是最后一项,函数名里的 ready 不能替代具体完成条件。后文会通过暂停服务响应的实验,直接观察函数在预载完成之前返回。

1.2 模型与服务 ​

Provider ID仍是配置目录的键,LM Studio 使用 lmstudio;模型 ID来自服务的模型命名空间,默认是 openai/gpt-oss-20b。

源码文件:codex-rs/utils/oss/src/lib.rs

相关函数/类型:get_default_model_for_oss_provider

rust
// 入口按 Provider ID 选默认模型;LM Studio 与 Ollama 的模型命名不同。
pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static str> {
    match provider_id {
        LMSTUDIO_OSS_PROVIDER_ID => Some(codex_lmstudio::DEFAULT_OSS_MODEL),
        OLLAMA_OSS_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL),
        _ => None,
    }
}

两种本地 Provider 的默认字符串不同。源码没有把 Ollama 的 gpt-oss:20b 自动转换成 LM Studio 的 openai/gpt-oss-20b。即使底层权重来自同一模型家族,也需要按所选服务返回的名称传递参数。

源码文件:codex-rs/exec/src/lib.rs

相关函数/类型:run_main

rust
// ...
// When using `--oss`, let the bootstrapper pick the model based on selected provider
// 显式 -m 优先;选择结果进入随后构造的 ConfigOverrides。
let model = if let Some(model) = model_cli_arg {
    Some(model)
} else if oss {
    model_provider
        .as_ref()
        .and_then(|provider_id| get_default_model_for_oss_provider(provider_id))
        .map(std::borrow::ToOwned::to_owned)
} else {
    None // No model specified, will use the default.
};
// ...

exec 的 run_main 把这里选出的 model 与 Provider ID 放进 ConfigOverrides,然后构造正式 Config。TUI 有对应入口逻辑。显式 -m 先被采用;启用 OSS 且没有显式模型时,入口用上述共用函数选择默认模型。因此只在配置里改一般模型字段,并不意味着它会压过这份启动 override。

命令示例:

bash
codex --oss --local-provider lmstudio -m openai/gpt-oss-20b

这条命令会执行实际准备,模型不在列表时可触发下载。只写 --local-provider 不会自动把 --oss 打开;非交互的 exec 未解析出 Provider ID 时会报错,TUI 则可以进入服务检测与选择。

1.3 两条外部边界 ​

LM Studio 的 HTTP 服务和 lms 命令是两个独立的外部入口。配置的 base_url 用于 HTTP;下载由本机发现的可执行文件承担。下面把这两个边界放到同一张图里,避免把“本地 Provider”理解成一次完全在 Codex 进程内部完成的调用。

图中没有从 base_url 到 lms 参数的边,因为 Codex 只给下载器传递 get、--yes 和模型名,没有同步传递 HTTP 服务地址。若 CODEX_OSS_BASE_URL 指向另一台机器,不能由这段代码保证本机 lms 下载的内容会进入那台机器的服务目录;下载器自身的环境与行为需要单独核对。

2. HTTP 探测 ​

2.1 地址与对象 ​

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:LMStudioClient

rust
#[derive(Clone)]
pub struct LMStudioClient {
    // Clone 会克隆池句柄;base_url 是独立的 String 副本。
    client: RouteAwareClientPool,
    base_url: String,
}

客户端只有 HTTP 池和地址字符串,Clone 为后台请求准备所有权。它不记录“已下载”“已加载”或“正在推理”,因此任何这些状态都不能从一个客户端实例是否还存在来判断。

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:LMStudioClient::try_from_provider

rust
pub async fn try_from_provider(config: &Config) -> std::io::Result<Self> {
    let provider = config
        .model_providers
        .get(LMSTUDIO_OSS_PROVIDER_ID)
        .ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::NotFound,
                format!("Built-in provider {LMSTUDIO_OSS_PROVIDER_ID} not found",),
            )
        })?;
    // 缺少地址是 InvalidData;不会用另一个默认地址悄悄继续。
    let base_url = provider.base_url.as_ref().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            "oss provider must have a base_url",
        )
    })?;

    // 5 秒只配置连接建立时间,未设置请求总超时。
    let client = RouteAwareClientPool::with_connect_timeout(
        config.http_client_factory(),
        ClientRouteClass::Other,
        LMSTUDIO_CONNECTION_TIMEOUT,
    );

    let client = LMStudioClient {
        client,
        base_url: base_url.to_string(),
    };
    client.check_server().await?;

    Ok(client)
}

构造函数读的是正式配置中的内置 lmstudio 条目。缺少条目返回 NotFound;条目没有 base_url 返回 InvalidData。这比连接错误更早发生,没有发出 HTTP 请求。与 Ollama 内部构造函数的 expect 不同,这里明确把地址缺失作为可返回的错误处理。

内置地址由 codex-rs/model-provider-info/src/lib.rs 的 create_oss_provider 生成:默认 http://localhost:1234/v1,完整的 CODEX_OSS_BASE_URL 优先于端口生成结果。非 Bedrock 内置键的同名配置仍由 merge_configured_model_providers 的 or_insert 保留内置值;不能假定 [model_providers.lmstudio] 会覆盖默认地址。该公共行为已在 Ollama 的地址与传输中展开。

LM Studio 客户端不移除 /v1,只移除末尾斜杠后直接拼接相对后缀。

base_url探测与列表路径预载路径
http://localhost:1234/v1/v1/models/v1/responses
http://host/prefix/v1//prefix/v1/models/prefix/v1/responses
http://host/models/responses

这里没有像 Ollama 那样区分原生 /api/* 和兼容接口,也没有自动补 /v1。自定义地址应当给出服务真实的兼容根路径。带 query 的地址同样不能指望字符串拼接替你维护正确的路径与查询参数关系。

RouteAwareClientPool::with_connect_timeout 接收 Config::http_client_factory() 和 ClientRouteClass::Other。它保留配置中的代理路由选择;5 秒限制连接建立,而非整个响应或预载过程。这一客户端没有调用 Ollama 构造时使用的 with_legacy_custom_ca_fallback;池默认的 CustomCaFallback::Disabled 不会被本模块主动改为旧兼容回退。

2.2 成功状态 ​

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:LMStudioClient::check_server

rust
async fn check_server(&self) -> io::Result<()> {
    let url = format!("{}/models", self.base_url.trim_end_matches('/'));
    let response = self.client.get(&url).send().await;

    // 只验证状态;没有读取 body,也没有验证 data 数组。
    if let Ok(resp) = response {
        if resp.status().is_success() {
            Ok(())
        } else {
            Err(io::Error::other(format!(
                "Server returned error: {} {LMSTUDIO_CONNECTION_ERROR}",
                resp.status()
            )))
        }
    } else {
        // 所有请求层错误被统一映射,细分原因没有在此保留。
        Err(io::Error::other(LMSTUDIO_CONNECTION_ERROR))
    }
}

这次 GET 只验证状态。200 空响应体也会通过,data 数组和模型名称在下一次 GET 才检查。因此一条启动日志中出现两次 /models 并不自动表示发生了重试;正常启动本来就先探测一次,再取列表一次。

非成功 HTTP 状态保留状态码,并附带启动服务的提示;请求层错误统一变成 LM Studio is not responding。路由选择、自定义 CA 或连接失败在此不会保留各自的错误细节。如果该提示与实际服务状态不符,应继续检查 HTTP Client 路由与中间件,而不是把所有情况都判断为未安装 LM Studio。

客户端只从 Provider 条目取 base_url,没有把该条目的认证、http_headers、env_http_headers 或 query_params 应用到这些管理请求。底层 HTTP 会自行生成 Host 等协议头,预载函数也会显式设置 Content-Type,但这不等于复用了正式模型请求的整套认证上下文。需要认证的自定义网关应分别核对准备请求与正式推理请求。

2.3 前台步骤 ​

接下来读库入口的前半段。这里的 await 形成清晰的先后关系:构造探测成功,才会读取模型列表;列表精确缺失,才会调用下载。

源码文件:codex-rs/lmstudio/src/lib.rs

相关函数/类型:ensure_oss_ready

rust
// ...
pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> {
    let model = match config.model.as_ref() {
        Some(model) => model,
        None => DEFAULT_OSS_MODEL,
    };

    // Verify local LM Studio is reachable.
    // 探测失败通过 ? 停止,客户端成功才会继续查列表。
    let lmstudio_client = LMStudioClient::try_from_provider(config).await?;

    match lmstudio_client.fetch_models().await {
        Ok(models) => {
            if !models.iter().any(|m| m == model) {
                lmstudio_client.download_model(model).await?;
            }
        }
        // 列表失败只放行到后面的预载,不代表模型已经存在。
        Err(err) => {
            // Not fatal; higher layers may still proceed and surface errors later.
            tracing::warn!("Failed to query local models from LM Studio: {}.", err);
        }
    }
// ...

model 在此借用 config.model 的字符串,未设置时用库常量兜底。整个前台过程仍由调用者持有的 future 执行;模型列表错误被截获为 warning,而下载错误通过 ? 返回。后台预载的创建位于这段代码之后,所以只有真正返回的错误才能阻止它被提交。

下面的时序图表达这些等待点。浅色阶段框覆盖前台步骤,随后创建的后台 task 与入口返回不具有“加载完成再返回”的关系。

图中下载分支只表示 Ok(models) 后的未命中。若第二次列表请求失败,代码会直接走向后台预载;若第一次探测失败,则连第二次 GET 都不会发生。

3. 列表判定 ​

3.1 数组契约 ​

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:LMStudioClient::fetch_models

rust
pub async fn fetch_models(&self) -> io::Result<Vec<String>> {
    let url = format!("{}/models", self.base_url.trim_end_matches('/'));
    let response = self
        .client
        .get(&url)
        .send()
        .await
        .map_err(|e| io::Error::other(format!("Request failed: {e}")))?;

    if response.status().is_success() {
        // 解析失败为 InvalidData;成功 JSON 还需满足下一步的数组条件。
        let json: serde_json::Value = response.json().await.map_err(|e| {
            io::Error::new(io::ErrorKind::InvalidData, format!("JSON parse error: {e}"))
        })?;
        let models = json["data"]
            .as_array()
            .ok_or_else(|| {
                io::Error::new(io::ErrorKind::InvalidData, "No 'data' array in response")
            })?
            .iter()
            // 只保留字符串 id,没有去重、别名展开或驻留状态判断。
            .filter_map(|model| model["id"].as_str())
            .map(std::string::ToString::to_string)
            .collect();
        Ok(models)
    } else {
        Err(io::Error::other(format!(
            "Failed to fetch models: {}",
            response.status()
        )))
    }
}

fetch_models 比 check_server 多了两层契约:响应必须能解码为 JSON,且 json["data"] 必须是数组。前者失败形成 JSON parse error,后者失败形成 No 'data' array in response,都使用 io::ErrorKind::InvalidData。非成功 HTTP 状态也返回错误,并没有被转换成空模型列表。

数组中的条目只读取字符串 id。其他类型和缺失字段被跳过,重复字符串和空字符串仍然保留。比如输入 data: [{id:"lesson/model"},{id:9},{},{id:"lesson/model"},{id:""}],输出就是两个 lesson/model 加一个空字符串。这是一份名称投影,不是完整模型目录、更不是驻留状态表。

当 ensure_oss_ready 使用 models.iter().any(|m| m == model) 时,它只做精确匹配。量化后缀、别名、厂商前缀或大小写都没有本地归一化;请求模型名如何对应服务内部模型由 LM Studio 自身解释。

3.2 降级后果 ​

因为入口把 fetch_models 的错误记录成 warning,错误形式会影响下一步是否下载。把成功空数组与损坏列表分开,才能判断为什么一次启动调用了 lms,另一次没有。

第二次 /models 响应列表函数下载后台预载
{"data":[{"id":"lesson/model"}]},请求同名Ok跳过提交
{"data":[]}Ok([])等待下载成功下载成功后提交
{"data":[{"id":"another/model"}]}Ok等待下载成功下载成功后提交
HTTP 503Err跳过仍提交
HTTP 200,{}InvalidData跳过仍提交
HTTP 200,not-jsonInvalidData跳过仍提交
网络请求失败Err跳过仍提交

这里是 best effort 查询:无法读取列表时,允许后续真实请求再暴露问题,并不把失败解释成“确定缺少模型”。它与 Ollama 将列表非成功状态转换成 Ok([]) 的行为不同;共同的函数命名不代表两个实现共享同样的错误策略。

另一个细节是下载成功后没有再次调用 fetch_models。子进程退出码是前台唯一的下载完成依据,HTTP 服务是否已刷新模型目录没有第二次确认。诊断“下载显示成功,预载却报模型不存在”时,应把外部下载环境、服务目录刷新和模型 ID 三者分开,而不能直接归因于本地缓存损坏。

4. 下载进程 ​

4.1 可执行文件 ​

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:LMStudioClient::find_lms / find_lms_with_home_dir

rust
fn find_lms() -> std::io::Result<String> {
    Self::find_lms_with_home_dir(/*home_dir*/ None)
}

fn find_lms_with_home_dir(home_dir: Option<&str>) -> std::io::Result<String> {
    // First try 'lms' in PATH
    // PATH 命中时返回命令名,不保留 which 查出的绝对路径。
    if which::which("lms").is_ok() {
        return Ok("lms".to_string());
    }

    // Platform-specific fallback paths
    let home = match home_dir {
        Some(dir) => dir.to_string(),
        None => {
            #[cfg(unix)]
            {
                std::env::var("HOME").unwrap_or_default()
            }
            #[cfg(windows)]
            {
                std::env::var("USERPROFILE").unwrap_or_default()
            }
        }
    };

    #[cfg(unix)]
    let fallback_path = format!("{home}/.lmstudio/bin/lms");

    #[cfg(windows)]
    let fallback_path = format!("{home}/.lmstudio/bin/lms.exe");

    // exists 只证明路径存在,不能证明它是可执行文件。
    if Path::new(&fallback_path).exists() {
        Ok(fallback_path)
    } else {
        Err(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "LM Studio not found. Please install LM Studio from https://lmstudio.ai/",
        ))
    }
}

find_lms 将 None 交给可注入 home 的内部 helper。helper 先查 PATH;命中后返回裸字符串 lms,没有返回 which 解析到的完整路径。后面的 Command::new 会按启动环境再次解析命令名。

PATH 没有命中时,Unix 使用 HOME,Windows 使用 USERPROFILE,再拼出 .lmstudio/bin/lms 或 .lmstudio/bin/lms.exe。可注入的 home_dir 便于测试回退路径,却不会覆盖 PATH 优先级:只要 PATH 有可执行 lms,传什么 home 都不会进入回退分支。

回退分支只调用 Path::exists。存在的普通文本文件或目录都能通过这一步,但不意味着随后 Command::status 可以执行它。因此“找到了 lms 路径”和“成功创建了下载进程”必须在错误分析中分开。

下图保留了发现、启动和退出三个判断点。红色节点是实际执行边界,绿色节点是本地 Provider 的选择逻辑,灰色节点表示操作系统环境输入。

Windows 与 Unix 的差异在编译期 cfg 路径里表达。下载参数和结果判断没有另建两套流程;测试运行于哪一个平台,决定了哪条 fallback 分支被实际执行,不能把一次 Unix 测试当成 Windows 可执行文件搜索的运行证明。

4.2 同步等待 ​

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:LMStudioClient::download_model

rust
pub async fn download_model(&self, model: &str) -> std::io::Result<()> {
    let lms = Self::find_lms()?;
    eprintln!("Downloading model: {model}");

    // 标准库同步等待占用当前线程;async 签名没有把子进程等待异步化。
    let status = std::process::Command::new(&lms)
        // 模型作为独立 argv 传递;没有 shell 字符串插值。
        .args(["get", "--yes", model])
        .stdout(std::process::Stdio::inherit())
        // 下载器的 stderr 被丢弃,错误返回中只剩执行/退出状态。
        .stderr(std::process::Stdio::null())
        .status()
        .map_err(|e| {
            std::io::Error::other(format!("Failed to execute '{lms} get --yes {model}': {e}"))
        })?;

    if !status.success() {
        return Err(std::io::Error::other(format!(
            "Model download failed with exit code: {}",
            status.code().unwrap_or(-1)
        )));
    }

    tracing::info!("Successfully downloaded model '{model}'");
    Ok(())
}

download_model 虽然声明为 async fn,函数体内没有 await,使用的是 std::process::Command。调用方一旦轮询到这里,就会同步执行文件搜索、子进程创建和 status() 等待;下载期间当前执行线程被占用。

这与 tokio::process::Command::status().await 或把阻塞工作放进 spawn_blocking 的语义不同。在单线程 runtime 上,其他已就绪的异步任务也要等这一轮调用返回后才有机会执行;多线程 runtime 中其他 worker 可继续工作,但当前 worker 仍被占用。仅给外层 future 包一个超时,不能自动让这个同步等待中途交回调度器。

模型名通过 .args(["get", "--yes", model]) 作为单独参数传递。带空格或分号的模型字符串不会被 Codex 拼进 shell 程序;它仍可能被 lms 判断为无效模型,但那是下载器的参数语义。命令继承默认的进程环境和工作目录,源码没有根据 self.base_url 设置 --host,也没有把 Config.cwd 单独设为子进程 cwd。

4.3 输出与退出 ​

stdout 被继承,所以下载器的标准输出直接进入 Codex 当前 stdout;stderr 指向空设备,下载器写到错误通道的诊断会丢失。stdin 没有被本函数显式改写;--yes 是交给下载器的确认参数,而不是 Codex 自己读取或回答一个交互请求。

启动失败与退出失败分别形成错误:文件不能执行等情况进入 Failed to execute;启动成功但非零退出进入 Model download failed with exit code。Unix 子进程被信号终止时,status.code() 为 None,源码用 -1 呈现。这个 -1 不是下载器实际返回的一个普通退出码,而是“没有普通退出码”的本地替代值。

由于下载错误通过入口的 ? 传播,后台预载在这些失败后不会被创建。相反,exit 0 只会记录下载成功并进入下一步,没有分析 stdout,也没有通过服务 API 验证下载结果。恢复动作应对应错误阶段:找不到或不能执行时检查程序发现;非零退出时直接诊断下载器,因为 Codex 保存的 stderr 信息有限;退出成功后仍不可见时检查 HTTP 服务与下载目标的关系。

5. 后台预载 ​

5.1 克隆与持有 ​

前台步骤通过后,入口剩余部分创建一个 Tokio task,然后返回。

源码文件:codex-rs/lmstudio/src/lib.rs

相关函数/类型:ensure_oss_ready

rust
// ...
    // Load the model in the background
    // 未保存 JoinHandle;此处提交工作后即可返回,并未等待加载完成。
    tokio::spawn({
        // move future 必须拥有客户端和模型名,不能借用即将返回的栈变量。
        let client = lmstudio_client.clone();
        let model = model.to_string();
        async move {
            if let Err(e) = client.load_model(&model).await {
                tracing::warn!("Failed to load model {}: {}", model, e);
            }
        }
    });

    Ok(())
}
// ...

这里把借用的模型名转成 String,并克隆客户端,让 async move 拥有其捕获值。外层函数返回之后,原 lmstudio_client 和借用的局部变量可以结束生命周期,后台 future 仍然有自己的客户端句柄与模型字符串。

克隆客户端也不意味着重新建立一份完全独立的 HTTP 连接池。沿字段继续读到池定义,会看到共享路由缓存。

源码文件:codex-rs/http-client/src/route_aware_client_pool.rs

相关函数/类型:RouteAwareClientPool

rust
#[derive(Clone)]
pub struct RouteAwareClientPool {
    http_client_factory: HttpClientFactory,
    route_class: ClientRouteClass,
    client_builder: HttpClientBuilder,
    custom_ca_fallback: CustomCaFallback,
    // Arc 使 clone 共享路由缓存;Mutex 保护缓存映射,不表示共享模型就绪状态。
    clients: Arc<Mutex<HashMap<OutboundProxyRoute, HttpClient>>>,
    rustls_clients: Option<RustlsClientCache>,
}

clients 通过 Arc 共享,Mutex 保护路由到 HttpClient 的映射。客户端上的 base_url: String 则被复制。这样的 clone 足以延长传输资源的生命周期,但没有生成或共享“模型已加载”的布尔值、通知器或 promise。

下面只画真实类型之间的关系;后台 future 拿到的是一个克隆的 LMStudioClient,而不是一个带新生命周期管理功能的模型 manager。

图中的锁是传输缓存锁,不是等待预载结束的同步原语。若想让首次请求等待预载,单靠共享 Arc 不会建立所需的先后关系,必须额外设计完成信号及其失败、超时和取消契约。

5.2 无等待句柄 ​

tokio::spawn 返回的 JoinHandle 没有绑定到变量,也没有存入 LMStudioClient 或 Session。按照 Tokio JoinHandle 的语义,drop 句柄会把任务分离,使它可以继续运行,但调用方失去了通过该句柄等待和获取结果的入口。

因而 ensure_oss_ready 的 Ok(()) 发生在“提交 task”之后,不要求 task 已经被轮询,也不要求 HTTP 请求已经发出。调度器可以很快启动它,也可以先继续入口的其他工作;源码没有承诺两者之间的更强顺序。

后台错误被 if let Err(e) 转成 warning,不能再改写已经返回给前台的 Result。这一 task 也没有加入 Core Turn 的取消 token,没有在此保存 abort handle,没有加载失败重试循环。运行时关闭与远端是否继续处理请求属于更外层的生命周期;本模块没有发送卸载请求或等待服务器释放资源的逻辑。

每次调用 ensure_oss_ready 都会走到一次新的 spawn,不按模型名合并预载。HTTP 池的共享缓存只能复用传输资源,不能被解读为预载去重。若嵌入方重复调用这个 helper,应按实际调用次数分析请求数量。

5.3 最小请求 ​

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:LMStudioClient::load_model

rust
pub async fn load_model(&self, model: &str) -> io::Result<()> {
    let url = format!("{}/responses", self.base_url.trim_end_matches('/'));

    // 这是预载触发请求,不含会话历史、工具或 stream=true。
    let request_body = serde_json::json!({
        "model": model,
        "input": "",
        "max_output_tokens": 1
    });

    let response = self
        .client
        .post(&url)
        .header("Content-Type", "application/json")
        .json(&request_body)
        .send()
        .await
        .map_err(|e| io::Error::other(format!("Request failed: {e}")))?;

    // 成功条件到 Header 状态为止;不消费或验证响应 JSON。
    if response.status().is_success() {
        tracing::info!("Successfully loaded model '{model}'");
        Ok(())
    } else {
        Err(io::Error::other(format!(
            "Failed to load model: {}",
            response.status()
        )))
    }
}

预载通过空输入和 max_output_tokens: 1 的 Responses 请求触发。源码注释提到 max_tokens,实际 JSON 字段是 max_output_tokens;实现分析和复现请求应使用后者。

这个请求没有 stream: true,没有用户历史、工具定义或 Turn metadata。它只是在真正对话之前尝试触发服务的模型准备工作,并没有调用一项 Codex 专门维护的 GPU 加载 API。

更关键的是成功条件:send().await 得到响应后,只检查 response.status().is_success()。没有调用 response.json()、response.text(),也没有等待 SSE 完成。因此 HTTP 200 的 body 即使是非法 JSON,load_model 仍可返回成功;服务如果先发送成功 Header、延迟响应体,该函数也不会等待整个响应体。

日志 Successfully loaded model 必须按这条实际判断解释:客户端收到成功 HTTP 状态。它不能独立证明权重已经驻留 GPU、未来不会被服务卸载或本次模型能力足够。对于普通非流式服务,成功状态往往伴随请求处理成功;这里需要明确的是 Codex 自身没有再验证这些更强条件。

6. 首次推理 ​

6.1 缺少完成屏障 ​

正式推理不经过 LMStudioClient::load_model。启动流程继续创建或连接会话后,一次 Turn,即由用户输入推动的执行周期,会通过 ModelClientSession 的通用请求路径发送模型请求。后台预载与这条路径之间没有 join、channel 或完成通知。

下图展示一种源码允许的交错,而不是承诺每次请求都按同样先后顺序抵达服务器。入口返回后,后台最小请求与首个正式请求可以重叠。

如果服务在推理时自动加载模型,正式请求仍可能成功,但是否等待、复用同一次加载或与预载竞争由服务实现决定。Codex 这里没有证明“预载一定消除首次延迟”,也没有因预载失败自动换一个模型。

6.2 正式消费者 ​

沿 codex-rs/core/src/client.rs 的 ModelClientSession::stream_responses_api,正式请求使用当前 Provider setup、认证与 telemetry 构造 API client,再消费流。

源码文件:codex-rs/core/src/client.rs

相关函数/类型:ModelClientSession::stream_responses_api

rust
// ...
let client = ApiResponsesClient::new(
    transport,
    client_setup.api_provider,
    client_setup.api_auth,
)
.with_telemetry(Some(request_telemetry), Some(sse_telemetry));
// 正式请求消费 Responses 事件流,与 LMStudioClient 预载的状态检查分离。
let stream_result = client.stream_request(request, options).await;

match stream_result {
    Ok(stream) => {
        let (stream, _) = map_response_stream(
            stream,
            request_session_telemetry,
            inference_trace_attempt,
            Arc::clone(&self.client.state.provider),
        );
        return Ok(stream);
    }
// ...

这里的 ApiResponsesClient 是 API 层 ResponsesClient 的导入别名。成功得到响应流之后,map_response_stream 还要把其事件映射为 Core 消费的流。与预载只检查 HTTP 状态不同,正式链路继续依赖消息事件与完成协议,错误也进入模型调用自己的传播路径。

源码文件:codex-rs/codex-api/src/endpoint/responses.rs

相关函数/类型:ResponsesClient::stream_encoded

rust
async fn stream_encoded(
    &self,
    body: EncodedJsonBody,
    extra_headers: HeaderMap,
    compression: Compression,
    turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<ResponseStream, ApiError> {
    let request_compression = match compression {
        Compression::None => RequestCompression::None,
        Compression::Zstd => RequestCompression::Zstd,
    };

    // 正式推理要求 SSE;连接成功之后仍有事件解码与完成检测。
    let stream_response = self
        .session
        .stream_encoded_json_with(
            Method::POST,
            Self::path(),
            extra_headers,
            Some(body),
            |req| {
                req.headers.insert(
                    http::header::ACCEPT,
                    HeaderValue::from_static("text/event-stream"),
                );
                req.compression = request_compression;
            },
        )
        .await?;

    Ok(spawn_response_stream(
        stream_response,
        self.session.provider().stream_idle_timeout,
        self.sse_telemetry.clone(),
        turn_state,
    ))
}

stream_encoded 明确加入 Accept: text/event-stream,把返回响应交给 spawn_response_stream,并传入 Provider 的流空闲超时与 SSE telemetry。这些消费者在本地预载函数中都不存在,不能把正式 Responses 的重试、认证恢复和流式超时规则自动套用到 load_model 上。

比较项后台预载正式模型请求
入口LMStudioClient::load_modelModelClientSession::stream_responses_api
输入空字符串、模型名、输出上限 1Prompt、历史、工具与模型参数
返回值消费者后台 future,只记录日志Core 的模型事件流消费者
完成依据成功 HTTP 状态API 流建立后继续解析事件
失败效果warning,准备入口已返回进入正常模型错误处理
与首次 Turn 的关系没有等待屏障Turn 自己等待请求结果

内置 LM Studio Provider 的 wire API 同样是 Responses,默认未启用 WebSocket。这里没有 Ollama 那种 /api/version 最低版本检查,也不会发现 /responses 不可用后自动退回 /chat/completions。服务能返回 /models 只通过了发现接口这一层,不能证明完整的 Responses、工具调用或多模态兼容性。

更深入的协议断流和失败分类可以继续读 Responses 流解析与模型错误分类。在排查时,应先确认失败属于最小预载还是正式 Turn,避免用一条后台 warning 解释所有后续模型行为。

7. 请求对照 ​

7.1 超时与外形 ​

上游测试中最有助于理解传输语义的是 test_check_server_allows_slow_response_after_connect。下面保留它的 mock 延迟和关键调用。

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:test_check_server_allows_slow_response_after_connect

rust
// ...
// 100 毫秒是连接限制;250 毫秒响应延迟仍必须通过。
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
    .and(wiremock::matchers::path("/models"))
    .respond_with(
        wiremock::ResponseTemplate::new(200).set_delay(Duration::from_millis(250)),
    )
    .mount(&server)
    .await;

let client = client_from_host_root(server.uri(), Duration::from_millis(100));

client
    .check_server()
    .await
    .expect("server check should allow a slow response after connecting");
// ...

客户端建连限制只有 100 毫秒,服务却在 250 毫秒后才给出响应,测试仍要求成功。这直接反证“建连超时会限制整个请求”的说法。它测试的是本地 mock 快速建连后的响应延迟,不覆盖 DNS、真实代理发现或五秒之外所有网络条件。

另一个测试从 HTTP 成功进一步检查 JSON 外形。

源码文件:codex-rs/lmstudio/src/client.rs

相关函数/类型:test_fetch_models_no_data_array

rust
// ...
// HTTP 成功且 JSON 合法仍不满足 data 数组契约,断言错误文本。
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
    .and(wiremock::matchers::path("/models"))
    .respond_with(
        wiremock::ResponseTemplate::new(200)
            .set_body_raw(serde_json::json!({}).to_string(), "application/json"),
    )
    .mount(&server)
    .await;

let client = client_from_host_root(server.uri(), LMSTUDIO_CONNECTION_TIMEOUT);
let result = client.fetch_models().await;
assert!(result.is_err());
assert!(
    result
        .unwrap_err()
        .to_string()
        .contains("No 'data' array in response")
);
// ...

输入是 HTTP 200 与合法 JSON {},断言 fetch_models 返回包含 No 'data' array in response 的错误。它证明成功 HTTP 与合法 JSON 仍不足以构成模型列表。把同样的响应接到 ensure_oss_ready 后,错误被转为 warning,后续预载仍会提交;这一步需要另看入口消费者,单测列表方法不能替代它。

在源码仓库根目录执行:

bash
cd codex-rs
just test -p codex-lmstudio -p codex-utils-oss --locked --lib

LM Studio crate 有 8 项测试,共用 OSS helper 有 3 项默认模型测试。HTTP 测试同样带 CODEX_SANDBOX_NETWORK_DISABLED guard;分析运行结果时应确认 guard 没有使测试主体提前返回。

两个 lms 搜索测试的范围也要细读:test_find_lms 接受“找到程序”或带指定提示的 NotFound;test_find_lms_with_mock_home 只在得到错误时检查错误文字,而且 PATH 仍然优先。它们不能证明回退路径实际可执行,也没有验证下载进程、参数或退出状态。

7.2 替身下载器 ​

针对外部进程,可以把一个仅记录参数的 lms 替身放在隔离子进程的 PATH 首位,让原始 download_model 调用它。替身只写入实验临时目录,不下载模型;根据用例返回 0、返回 7、被信号终止,或者短暂等待。这里要调用真实下载函数,不能用自己重写的进程启动代码替代被研究对象。

输入与安排关键断言说明的边界
模型 lesson model; echo unexecutedargv 恰为 get、--yes、整个模型字符串没有 shell 拆分;不验证模型 ID 合法性
替身 exit 0准备返回成功,后续出现一次预载 POST退出成功会继续,但没有列表复查
替身 exit 7返回错误含 7,预载 POST 为 0 次下载错误阻止创建后台任务
替身被 SIGTERM 终止错误中替代码为 -1,预载 POST 为 0 次Unix 非普通退出的展示语义
替身分别写 stdout/stderrstdout 标记被父进程收到,stderr 标记缺失继承/丢弃通道与源码一致
单线程 runtime,替身等待 200 毫秒25 毫秒 timer 在下载返回前尚未运行,返回后才运行同步 status 占用执行线程

timer 实验先等待计时任务完成注册,再执行原始下载方法,避免“任务根本没开始”导致错误结论。它观察的是单线程调度受阻,不说明所有多线程 runtime 的其他 worker 也会停下。

文件搜索可以独立验证:让 PATH 中没有 lms,向 find_lms_with_home_dir 传入实验目录,再分别放入不可执行普通文件和同名目录。两者都会因 exists() 返回路径;完全没有该路径时才得到 NotFound。恢复 PATH 中的可执行替身后,即使注入另一份 home,返回值也仍为裸 lms。这些实验避免改写用户的 HOME 或安装目录。

7.3 两道响应门 ​

预载时序适合用同步门验证,不需要凭几次运行耗时猜测。先让 mock /models 返回目标模型,避免发生下载;然后在测试服务里控制 /responses 的响应阶段。

第一道门挡住响应 Header:服务收到预载 POST 后先不返回状态。调用真实 ensure_oss_ready,确认它已经返回 Ok(()),再释放服务响应。这个顺序证明准备返回不等待后台 HTTP 请求完成;如果实现改成 join,这个用例会在释放响应前无法走到下一步。

第二道门放在 Header 与 body 之间:让服务立即返回 HTTP 200,并声明一个尚未发送完的 Content-Length,然后等待释放信号。直接调用真实 load_model,它会在响应体尚未释放时返回成功。这个用例把“收到成功 Header”与“消费完响应体”分开,比只返回一个错误 JSON 更明确地验证了完成边界。

补充的响应对照还包括 HTTP 200 携带非法 JSON,以及 HTTP 500。前者预载方法返回成功,后者返回 Failed to load model。若它们通过后台 task 执行,方法级失败只会进入 warning,已经返回的准备结果不会变成失败。

这些用例只证明 Codex 客户端的顺序与判断条件,不证明 LM Studio 服务在什么时候将权重载入 GPU,也不证明之后的正式推理请求能够成功。要验证后者,应把真实请求内容与 Responses 流解析要求的事件一起检查。

7.4 按现象定位 ​

对实际环境,先做不会下载或预载模型的只读查询。

bash
curl -i --max-time 5 http://localhost:1234/v1/models
command -v lms

第一条同时显示状态与响应体,可以检查路径、data 数组和精确 id;5 秒是 curl 诊断的总时限,不是代码中连接限制的同义写法。第二条只检查 PATH 命中,不能代替 Codex 额外执行的 fallback 检查,也不能证明程序能够正常下载。

现象应定位的代码与状态
只有一次 models 请求就退出构造里的 check_server 状态或传输错误
两次 models 请求后没有下载却有预载精确命中,或列表错误被 warning 放行
下载显示成功,HTTP 服务仍找不到模型lms 的目标环境、精确 ID、服务目录;入口没有复查
启动在下载期间长时间不响应异步工作download_model 的同步 Command::status
预载失败日志出现,但界面已可继续分离 task 的 warning,不会回写入口 Result
预载成功日志出现,首次 Turn 仍失败状态码检查与正式 Responses 事件/模型能力的差别
自定义远程服务可连,却在本机启动下载器HTTP base_url 没有变成 lms 的显式目标参数

可以用一个请求日志练习完整复述:观察到两次 GET /v1/models、一次 lms get --yes lesson/model 和一次空输入 POST /v1/responses,分别指出它们的调用点、谁等待谁,以及哪个返回点之后正式 Turn 才继续。再把第二次 GET 改成 503,预测下载是否发生、预载是否发生,并回到 ensure_oss_ready 的两个分支核对。

如果希望修改为“准备完成后首个 Turn 一定等待预载”,需要明确新增保证:保存加载结果或完成信号,决定失败是否阻止启动,为等待设置独立超时,并把取消接入调用者生命周期。还应决定成功依据是否要消费响应体。仅保存一个克隆客户端,或给现有 async fn 改名,都不会自动建立这些条件。