Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Codex can run fully locally by delegating inference to [LM Studio](https://lmstu
3. Run Codex with the LM Studio backend:

```shell
# Interactive session using the default LLaMA 3.1 8B Instruct model
# Interactive session using the default DevStral Small (LLaMA architecture) build
codex --backend lmstudio

# Explicitly pick one of the supported architectures
Expand All @@ -88,15 +88,18 @@ Codex can run fully locally by delegating inference to [LM Studio](https://lmstu

Codex understands the following architecture aliases when `--backend lmstudio` is selected:

| Alias | LM Studio model identifier |
| ---------- | --------------------------------------------------- |
| `llama` | `meta-llama/Meta-Llama-3.1-8B-Instruct` |
| `qwen2` | `Qwen/Qwen2-7B-Instruct` |
| `qwen3` | `Qwen/Qwen3-7B-Instruct` |
| `qwen3-moe`| `Qwen/Qwen3-MoE-A2.7B-Instruct` |
| Alias | LM Studio model identifier |
| -------------- | ------------------------------------- |
| `llama` | `mistralai/devstral-small-2507` |
| `qwen2` | `qwen/qwen2.5-coder-14b` |
| `qwen3` | `qwen/qwen3-4b-2507` |
| `qwen3-moe` | `qwen/qwen3-coder-30b` |
| `qwen3-moe-a3b`| `qwen/qwen3-30b-a3b-2507` |

You can also pass the exact LM Studio identifier (for example `my-org/custom-model`) if you are running a different checkpoint. Codex verifies that the requested model is available from LM Studio and surfaces clear errors when it is not.

When you select the LM Studio backend Codex automatically enables structured JSON output so the agent can reliably capture command results. No extra flags are required.

---

### Docs & FAQ
Expand Down
89 changes: 81 additions & 8 deletions codex-rs/core/src/chat_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use tokio::sync::mpsc;
use tokio::time::timeout;
use tracing::debug;
use tracing::trace;
use uuid::Uuid;

use crate::ModelProviderInfo;
use crate::client_common::Prompt;
Expand All @@ -23,6 +24,7 @@ use crate::error::CodexErr;
use crate::error::Result;
use crate::model_family::ModelFamily;
use crate::openai_tools::create_tools_json_for_chat_completions_api;
use crate::openai_tools::sanitize_json_schema;
use crate::util::backoff;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
Expand All @@ -35,12 +37,6 @@ pub(crate) async fn stream_chat_completions(
client: &reqwest::Client,
provider: &ModelProviderInfo,
) -> Result<ResponseStream> {
if prompt.output_schema.is_some() {
return Err(CodexErr::UnsupportedOperation(
"output_schema is not supported for Chat Completions API".to_string(),
));
}

// Build messages array
let mut messages = Vec::<serde_json::Value>::new();

Expand Down Expand Up @@ -274,13 +270,31 @@ pub(crate) async fn stream_chat_completions(
}

let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?;
let payload = json!({
let mut payload = json!({
"model": model_family.slug,
"messages": messages,
"stream": true,
"tools": tools_json,
});

if let Some(schema) = &prompt.output_schema
&& let Some(obj) = payload.as_object_mut()
{
let mut sanitized_schema = schema.clone();
sanitize_json_schema(&mut sanitized_schema);
obj.insert(
"response_format".to_string(),
json!({
"type": "json_schema",
"json_schema": {
"name": "codex_output_schema",
"schema": sanitized_schema,
"strict": true,
}
}),
);
}

debug!(
"POST to {}: {}",
provider.get_full_url(&None),
Expand Down Expand Up @@ -369,6 +383,14 @@ async fn process_chat_sse<S>(
active: bool,
}

impl FunctionCallState {
fn ensure_call_id(&mut self) -> String {
self.call_id
.get_or_insert_with(|| format!("tool_call_{}", Uuid::new_v4()))
.clone()
}
}

let mut fn_call_state = FunctionCallState::default();
let mut assistant_text = String::new();
let mut reasoning_text = String::new();
Expand Down Expand Up @@ -534,6 +556,8 @@ async fn process_chat_sse<S>(
// Extract call_id if present.
if let Some(id) = tool_call.get("id").and_then(|v| v.as_str()) {
fn_call_state.call_id.get_or_insert_with(|| id.to_string());
} else if fn_call_state.call_id.is_none() {
fn_call_state.ensure_call_id();
}

// Extract function details if present.
Expand Down Expand Up @@ -572,7 +596,7 @@ async fn process_chat_sse<S>(
id: None,
name: fn_call_state.name.clone().unwrap_or_else(|| "".to_string()),
arguments: fn_call_state.arguments.clone(),
call_id: fn_call_state.call_id.clone().unwrap_or_else(String::new),
call_id: fn_call_state.ensure_call_id(),
};

let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
Expand Down Expand Up @@ -623,6 +647,55 @@ async fn process_chat_sse<S>(
}
}

#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use futures::stream;
use tokio::time::Duration;

use crate::error::CodexErr;

#[tokio::test]
async fn generates_tool_call_id_when_missing() {
let chunks = vec![
Ok::<Bytes, CodexErr>(Bytes::from_static(
b"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"command\\\":[\\\"echo\\\"],\\\"timeout_ms\\\":1000}\"}}]}}]}\n\n",
)),
Ok::<Bytes, CodexErr>(Bytes::from_static(
b"data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n",
)),
];

let stream = stream::iter(chunks);
let (tx, mut rx) = mpsc::channel(8);
let handle = tokio::spawn(async move {
process_chat_sse(stream, tx, Duration::from_secs(5)).await;
});

let mut observed_call_id: Option<String> = None;
while let Some(event) = rx.recv().await {
match event.expect("stream event") {
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, .. }) => {
observed_call_id = Some(call_id);
}
ResponseEvent::Completed { .. } => break,
_ => {}
}
}

handle.await.expect("process_chat_sse task");

let call_id = observed_call_id.expect("missing tool call");
assert!(!call_id.is_empty(), "call_id should not be empty");
assert!(
call_id.starts_with("tool_call_"),
"unexpected fallback call_id prefix: {}",
call_id
);
}
}

/// Optional client-side aggregation helper
///
/// Stream adapter that merges the incremental `OutputItemDone` chunks coming from
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/core/src/openai_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ pub(crate) fn mcp_tool_to_openai_tool(
/// and otherwise defaults to "string".
/// - Fills required child fields (e.g. array items, object properties) with
/// permissive defaults when absent.
fn sanitize_json_schema(value: &mut JsonValue) {
pub(crate) fn sanitize_json_schema(value: &mut JsonValue) {
match value {
JsonValue::Bool(_) => {
// JSON Schema boolean form: true/false. Coerce to an accept-all string.
Expand Down
Loading
Loading