"Model metadata for `gpt-oss:20b` not found" with ollama

Resolved 💬 8 comments Opened Mar 15, 2026 by loonery Closed Mar 22, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

0.114.0

What subscription do you have?

None - I am using an Ollama provider

Which model were you using?

gpt-oss:20b

What platform is your computer?

Linux 6.18.17 x86_64 unknown (NixOS)

What terminal emulator and version are you using (if applicable)?

wezterm

What issue are you seeing?

When I try to interact with the Codex CLI, I see the warning message in the attached screenshot and quoted below. This was not a problem before using the 0.114.0 version of codex cli.

⚠ Model metadata for gpt-oss:20b not found. Defaulting to fallback metadata; this

can degrade performance and cause issues.

As the message notes, performance is degraded severely and it appears that when I try to interact with the agent after the message, that features like "plan mode" are not usable and the agent completely loses track of its goal.

Relevant config:

model = "gpt-oss:20b"
model_provider = "ollama"

[model_providers.ollama]
baseURL = "http://localhost:11434/v1"
name = "Ollama"

I have not found a workaround. I have found seemingly related issues (see list below), but none of the workarounds discussed there are working for me.

<img width="1275" height="468" alt="Image" src="https://github.com/user-attachments/assets/5ce350a6-25c3-4d55-83c7-27d0c028a8b8" />

What steps can reproduce the bug?

Open codex cli v0.114.0 configured to use gpt-oss:20b with ollama 0.17.7. Chat with the agent and see the message.

What is the expected behavior?

No warning message, agent stays on task.

Additional information

_No response_

View original on GitHub ↗

8 Comments

github-actions[bot] contributor · 4 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #14276

Powered by Codex Action

loonery · 4 months ago

I have mentioned the above potential duplicate in my issue. I do not think that this is a duplicate issue.

RoterLong · 4 months ago

May I ask if it has been solved?

loonery · 4 months ago

@RoterLong - it has not been solved.

In addition to the description that I left of the "missing metadata" I've also realized that the performance degradation I'm noticing is this bug: https://github.com/openai/codex/issues/11940 where the gpt-oss model no longer has access to 'apply_patch'

ninevoltz · 4 months ago

model-catalog.local.json

You need a "model-catalog.local.json" in your "~/.codex" directory.
Include it inside your config.toml with: model_catalog_json = "~/.codex/model-catalog.local.json"

To be honest, it feels like using local models with codex is going to end at some point. I'm amazed they included it at all, since it doesn't benefit OpenAI financially.

codex/codex-rs/core/models.json in the source tree has examples.

loonery · 4 months ago

@ninevoltz thank you for the response. That is working for me now. Originally it didn't work, but that was because the "slug" and "display_name" keys were "gpt-oss-20b" which did not match my specified model of "gpt-oss:20b".

loonery · 4 months ago

Closed this issue due to the discovered workaround.

rromanchuk · 2 months ago
model-catalog.local.json You need a "model-catalog.local.json" in your "~/.codex" directory. Include it inside your config.toml with: model_catalog_json = "~/.codex/model-catalog.local.json" To be honest, it feels like using local models with codex is going to end at some point. I'm amazed they included it at all, since it doesn't benefit OpenAI financially. codex/codex-rs/core/models.json in the source tree has examples.

Yeah, this approach barely scales for gpt-oss, but it makes perfect financial sense for current state. Bootstrap your harness, and earn good will, sit back and enjoy the secondary benefits of avoiding angry tweets, and placating youtube influencers with an open source + rust. @ninevoltz totally agree, this will be gone, but I'd argue it already is lol. I mean you "technically" can run local models, kind of.

To save everyone time and tokens, schema details: TLDR for /v1/responses drop any incompatible params via empty array or remove keys for optionals

Codex model_catalog_json Authoring Reference

Pinned to commit 3a23e87e203a0cebb57ee1aebf654b460c1e4e1d of github.com/openai/codex. Every claim below is grounded in a source file in codex-rs/; paths are relative to that root.

---

1. Full schema

ModelInfo is defined in protocol/src/openai_models.rs (lines 257–315). The JSON file you point model_catalog_json at must deserialize as ModelsResponse { models: Vec<ModelInfo> } (line 437).

| JSON field | Rust type | Required? | Default if omitted | Notes |
|---|---|---|---|---|
| slug | String | required | — | Identifier the model resolves against; also sent on the wire as model. |
| display_name | String | required | — | UI string. Not on the wire. |
| description | Option<String> | required key (null ok) | — | Field must be present (no #[serde(default)]); set to null to omit content. UI only. |
| default_reasoning_level | Option<ReasoningEffort> | optional | None | #[serde(default, skip_serializing_if = "Option::is_none")]. Wire effect: see §4. |
| supported_reasoning_levels | Vec<ReasoningEffortPreset> | required | — | Each item is { "effort": <ReasoningEffort>, "description": "..." }. UI picker only; not on wire. May be []. |
| shell_type | ConfigShellToolType | required | — | See §6 for behavior; §7 for accepted strings. |
| visibility | ModelVisibility | required | — | Picker visibility. "none" hides everywhere except direct slug requests. |
| supported_in_api | bool | required | — | UI/picker filter (ModelPreset::filter_by_auth). Not on wire. |
| priority | i32 | required | — | Lower wins in the picker sort. |
| additional_speed_tiers | Vec<String> | optional | [] | UI only; deprecated in favor of service_tiers. |
| service_tiers | Vec<ModelServiceTier> | optional | [] | Each entry: { "id": "...", "name": "...", "description": "..." }. UI only by default; id values become eligible service_tier wire strings if supports_service_tier accepts them. |
| availability_nux | Option<ModelAvailabilityNux> | required key (null ok) | — | { "message": "..." } or null. UI only. |
| upgrade | Option<ModelInfoUpgrade> | required key (null ok) | — | { "model": "...", "migration_markdown": "..." } or null. UI only. |
| base_instructions | String | required | — | The system prompt the wire sends as instructions when model_messages is null. Use the bundled prompt at models-manager/prompt.md as a starting point. |
| model_messages | Option<ModelMessages> | optional | None | #[serde(default, skip_serializing_if = "Option::is_none")]. Enables personality templating; see §4. |
| supports_reasoning_summaries | bool | required | — | Gates whether reasoning is sent at all. Critical. See §4. |
| default_reasoning_summary | ReasoningSummary | optional | "auto" | #[serde(default)]. Only consulted when supports_reasoning_summaries=true. |
| support_verbosity | bool | required | — | Gates text.verbosity on the wire (§4). |
| default_verbosity | Option<Verbosity> | required key (null ok) | — | Only used if support_verbosity=true. |
| apply_patch_tool_type | Option<ApplyPatchToolType> | required key (null ok) | — | Only enum variant is "freeform". See §6. |
| web_search_tool_type | WebSearchToolType | optional | "text" | #[serde(default)]. Shapes wire web_search tool, only if web search is enabled. |
| truncation_policy | TruncationPolicyConfig | required | — | { "mode": "bytes"\|"tokens", "limit": <i64> }. Used by output truncation; can be overridden at runtime by tool_output_token_limit. |
| supports_parallel_tool_calls | bool | required | — | Becomes the wire parallel_tool_calls. |
| supports_image_detail_original | bool | optional | false | #[serde(default)]. Gates whether view_image/inputs may request detail: "original". |
| context_window | Option<i64> | optional | None | #[serde(default, skip_serializing_if = "Option::is_none")]. Used for auto-compact threshold and UI. |
| max_context_window | Option<i64> | optional | None | Same. Clamps user model_context_window overrides. |
| auto_compact_token_limit | Option<i64> | optional | None | Same. Falls back to 0.9 * resolved_context_window(). |
| effective_context_window_percent | i64 | optional | 95 | #[serde(default = "default_effective_context_window_percent")]. |
| experimental_supported_tools | Vec<String> | required | — | String allowlist enabling experimental tools. Only "test_sync_tool" is read (see §6). May be []. |
| input_modalities | Vec<InputModality> | optional | ["text","image"] | #[serde(default = "default_input_modalities")]. Gates image_generation tool registration. |
| supports_search_tool | bool | optional | false | #[serde(default)]. Gates the tool_search tool. |

used_fallback_model_metadata is marked #[serde(skip_serializing, skip_deserializing)] and is internal-only.

Sub-structures

  • ReasoningEffortPreset (line 96): { "effort": <ReasoningEffort>, "description": String } — both fields required.
  • ModelServiceTier (line 119): { "id": String, "name": String, "description": String } — all required.
  • ModelAvailabilityNux (line 114): { "message": String } — required.
  • ModelInfoUpgrade (line 421): { "model": String, "migration_markdown": String } — both required.
  • ModelMessages (line 365): { "instructions_template": Option<String>, "instructions_variables": Option<ModelInstructionsVariables> } — both keys required, may be null.
  • ModelInstructionsVariables (line 394): all three keys required (personality_default, personality_friendly, personality_pragmatic), each Option<String>.
  • TruncationPolicyConfig (line 226): { "mode": "bytes"\|"tokens", "limit": <i64> } — both required.

Required vs optional rule of thumb

Any field annotated with #[serde(default)] or #[serde(default = "...")] is genuinely optional. Everything else must appear as a key, but Option<T> fields accept null. A test at openai_models.rs:782 (model_info_defaults_availability_nux_to_none_when_omitted) shows the full set of keys that work when the optional ones are stripped — but note that test still passes every "required key" with an explicit null.

---

2. Loader behavior

Path resolution

model_catalog_json is set in config.toml (or per-profile) as a path. Definition: config/src/config_toml.rs:350 and config/src/profile_toml.rs:40. Profile value wins over top-level (core/src/config/mod.rs:3145-3150):

let model_catalog = load_model_catalog(
    config_profile.model_catalog_json.clone()
        .or(cfg.model_catalog_json.clone()),
)?;

Deserialization

load_catalog_json (core/src/config/mod.rs:1532-1553):

let catalog = serde_json::from_str::<ModelsResponse>(&file_contents)
    .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, ...))?;
if catalog.models.is_empty() {
    return Err(...);  // empty catalog rejected
}
  • Unknown fields: silently ignored. No deny_unknown_fields on ModelInfo, ModelsResponse, or any nested struct. The bundled models-manager/models.json includes forward-compat fields (prefer_websockets, reasoning_summary_format, minimal_client_version, available_in_plans) that the current Rust struct ignores. Feel free to add comments-as-keys like _note.
  • Missing required fields: hard error from serde_json (e.g. "missing field slug"); the whole load fails and Codex refuses to start.
  • Invalid enum values: hard error ("unknown variant foo, expected one of …"). Same failure mode.
  • Empty catalog ({"models": []}): explicitly rejected: model_catalog_json path '...' must contain at least one model (line 1547). Tested at core/src/config/config_tests.rs:7395.
  • Whole-file parse error: wrapped as failed to parse model_catalog_json path '...' as JSON.

What happens after a successful load

The catalog is stuffed into Config.model_catalog: Option<ModelsResponse> (core/src/config/mod.rs:861). It is then handed to the model provider's models_manager(...) call at core/src/thread_manager.rs:226.

The provider's behavior when this value is Some (model-provider/src/provider.rs:238-260):

fn models_manager(&self, codex_home, config_model_catalog) -> SharedModelsManager {
    match config_model_catalog {
        Some(model_catalog) => Arc::new(StaticModelsManager::new(...)),
        None => /* OpenAiModelsManager with disk cache + /models refresh */
    }
}

Implication: setting model_catalog_json switches Codex to StaticModelsManager. The disk-cached models_cache.json and the upstream GET /models endpoint are never consulted. The catalog file becomes the authoritative, complete list. (manager.rs:382-407 is the entire StaticModelsManager impl — refresh_if_new_etag is a no-op.)

Partial-override fields in config.toml

These supplement a catalog entry at runtime via with_config_overrides (models-manager/src/model_info.rs:23-63):

| config.toml key | Reads field | Effect |
|---|---|---|
| model_context_window (i64) | model.context_window | Clamped to model.max_context_window if present. |
| model_auto_compact_token_limit (i64) | model.auto_compact_token_limit | Direct override; runtime auto_compact_token_limit() further clamps to 90% of resolved context. |
| tool_output_token_limit (usize) | model.truncation_policy | Recomputes the policy in the same mode as the catalog entry (Bytesapprox_bytes_for_tokens(...), Tokens → as-is). |
| model_supports_reasoning_summaries (bool) | model.supports_reasoning_summaries | Force-true only (if let Some(x) && x { model.supports_reasoning_summaries = true; }). Cannot force false. |
| experimental_compact_prompt_file / experimental_compact_prompt | model.base_instructions | Replaces base instructions; also clears model_messages (disables personality templating). |

If base_instructions override is absent and personality_enabled == false (in ModelsManagerConfig), model.model_messages is set to None even if you provided it.

---

3. Slug resolution

Entry point: construct_model_info_from_candidates (models-manager/src/manager.rs:459-478):

let remote = find_model_by_longest_prefix(model, candidates)
    .or_else(|| find_model_by_namespaced_suffix(model, candidates));
let model_info = if let Some(remote) = remote {
    ModelInfo { slug: model.to_string(), used_fallback_model_metadata: false, ..remote }
} else {
    model_info::model_info_from_slug(model)  // fallback
};
with_config_overrides(model_info, config)

Match order

  1. Longest-prefix match (find_model_by_longest_prefix, lines 422-438). The requested string must starts_with(candidate.slug). Among matches, the longest catalog slug wins.

Examples (assuming catalog has ["gpt-5", "gpt-5-codex"]):

  • request "gpt-5-codex-20250812" → matches "gpt-5-codex".
  • request "gpt-5" → matches "gpt-5".
  • request "gpt" → no match (prefix relationship is request starts_with slug, not vice versa).
  1. Single-namespace strip fallback (find_model_by_namespaced_suffix, lines 440-457). Tries once if step 1 missed:
  • Splits on the first / only; rejects strings with more than one /.
  • Namespace must be non-empty and match [A-Za-z0-9_\-]+.
  • Then re-runs longest-prefix on the suffix.
  • Example: request "openrouter/anthropic-claude-3-sonnet" with catalog slug "anthropic-claude-3" → matches.
  1. Fallback metadata (model_info::model_info_from_slug, model_info.rs:66-102). Logs Unknown model <slug> is used. This will use fallback model metadata. Returns:

``rust
ModelInfo {
slug: <requested>,
display_name: <requested>,
description: None,
default_reasoning_level: None,
supported_reasoning_levels: vec![],
shell_type: ConfigShellToolType::Default,
visibility: ModelVisibility::None,
supported_in_api: true,
priority: 99,
additional_speed_tiers: vec![],
service_tiers: vec![],
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS, // bundled prompt.md
model_messages: <special-case for "gpt-5.2-codex" and "exp-codex-personality" only>,
supports_reasoning_summaries: false,
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
apply_patch_tool_type: None,
web_search_tool_type: WebSearchToolType::Text,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
context_window: Some(272_000),
max_context_window: Some(272_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
experimental_supported_tools: vec![],
input_modalities: vec![Text, Image],
used_fallback_model_metadata: true,
supports_search_tool: false,
}
``

The slug-rewrite quirk

When step 1 or 2 matches, the resolved entry's slug is replaced with the user's full request string, not the catalog entry's slug (line 470). The wire request always sends model = <user's exact request>. The catalog slug is only the lookup key.

This means if you want the proxy to receive "meta-llama/Llama-3.3-70B-Instruct" but want to share metadata with a "llama-3.3" catalog entry, you can put "slug": "llama-3.3" in your catalog and the longest-prefix mechanic will pick it up — but you should generally just write out the exact wire string as the catalog slug to avoid surprises.

---

4. Field → wire effect mapping

The HTTP request body is built in core/src/client.rs:709-766 (build_responses_request). The body type is ResponsesApiRequest (codex-api/src/common.rs:169-190):

struct ResponsesApiRequest {
    model: String,
    instructions: String,            // omitted if empty
    input: Vec<ResponseItem>,
    tools: Vec<serde_json::Value>,
    tool_choice: String,
    parallel_tool_calls: bool,
    reasoning: Option<Reasoning>,
    store: bool,
    stream: bool,
    include: Vec<String>,
    service_tier: Option<String>,    // skip_serializing_if None
    prompt_cache_key: Option<String>,
    text: Option<TextControls>,      // skip_serializing_if None
    client_metadata: Option<HashMap<String, String>>,
}

Catalog → wire mapping

| Catalog field | Wire location | Translation |
|---|---|---|
| slug | model | Verbatim (user's request string, after slug rewrite per §3). |
| base_instructions | instructions | After personality template substitution if model_messages.instructions_template is set; otherwise raw. Empty string is skip_serializing_if-omitted. |
| model_messages.instructions_template | instructions | Replaces base_instructions when present; supports {{ personality }} placeholder. |
| model_messages.instructions_variables | instructions | Source of {{ personality }} value, keyed by Personality enum at runtime. |
| supports_parallel_tool_calls | parallel_tool_calls | Directly. turn.rs:993 reads turn_context.model_info.supports_parallel_tool_calls. |
| supports_reasoning_summaries | reasoning | Gates whole field. falsereasoning: null (i.e. no reasoning sent at all). true{effort, summary}. (client.rs:690-707) |
| default_reasoning_level | reasoning.effort | Used when the request doesn't override; None is allowed (Responses API accepts effort: null). |
| default_reasoning_summary | reasoning.summary | Used when not overridden. ReasoningSummary::None → wire summary: null; other values serialize to their lowercase string. |
| support_verbosity | text.verbosity | Gates field. false → never sent (warns at runtime if model_verbosity was configured). |
| default_verbosity | text.verbosity | Used when support_verbosity=true and no runtime override. |
| service_tiers[].id | service_tier | Only sent if a runtime service tier is requested and model_info.supports_service_tier(tier) returns true (client.rs:744-745). |
| web_search_tool_type | tools[].search_content_types (only on web_search tool entry) | "text" → field omitted; "text_and_image"["text", "image"]. Only applies if web_search is enabled by feature flags. (hosted_spec.rs:27-35) |
| apply_patch_tool_type | tools[] (only as a "custom" type entry) | Some(Freeform) adds the apply_patch freeform tool with lark grammar; None omits it. See §6. |
| shell_type | tools[] (shell or exec_command/write_stdin functions) | Determines which exec tool is registered. See §6 — not a direct wire field, but adds tool entries. |
| supports_search_tool | tools[] (tool_search) | Gates registration of the deferred-tool search tool. |
| experimental_supported_tools | tools[] | "test_sync_tool" registers TestSyncHandler. No other strings are read. |
| input_modalities | tools[] (image_generation) + image input acceptance | contains(Image) enables image_generation tool registration. |
| supports_image_detail_original | input.[].content[].detail | Sanitizes detail: "original" to auto on outgoing image references if false. |
| truncation_policy | not on wire | Used locally to truncate tool outputs before they go into input. |
| context_window / max_context_window / auto_compact_token_limit / effective_context_window_percent | not on wire | Local; control auto-compact and prompt budgeting. |

UI-only / picker-only fields

These never reach the wire and never affect tool registration:

  • display_name, description
  • supported_reasoning_levels (UI picker; the wire uses default_reasoning_level or runtime override directly)
  • visibility, supported_in_api, priority
  • additional_speed_tiers, service_tiers[].name, service_tiers[].description
  • availability_nux, upgrade

---

5. Hardcoded request fields

Things Codex sends that you cannot toggle from the catalog (or config.toml) without forking. From client.rs:746-764:

| Field | Value | Source |
|---|---|---|
| tool_choice | "auto" | Hardcoded string literal (line 751). |
| stream | true | Hardcoded bool literal (line 755). |
| store | provider.is_azure_responses_endpoint() | Effectively false for non-Azure providers; not catalog-controllable (line 754). |
| include | ["reasoning.encrypted_content"] if reasoning is set, else [] | Conditional on supports_reasoning_summaries, but the inclusion list itself is hardcoded (lines 722-726). |
| prompt_cache_key | thread/session id | Some(self.state.thread_id.to_string()) (line 743). |
| client_metadata | {"x-codex-installation-id": <uuid>} | Always sent; key is the constant X_CODEX_INSTALLATION_ID_HEADER (line 760-763). |
| parallel_tool_calls | direct from model.supports_parallel_tool_calls | Catalog-controlled, but always sent (no skip_serializing). |
| HTTP headers | session/auth headers from build_session_headers | Not catalog-controllable; see codex-api. |

Reasoning field serialization quirk

On ResponsesApiRequest itself (codex-api/src/common.rs:178), reasoning: Option<Reasoning> has no skip_serializing_if annotation. When supports_reasoning_summaries=false, the value is None and the wire body still contains "reasoning": null. Many non-OpenAI proxies tolerate this, but some (notably stricter Bedrock-style passthroughs) will reject an explicit null where the field isn't supported.

Inside the Reasoning struct (line 113-119), both effort and summary use skip_serializing_if = "Option::is_none", so a Some(Reasoning { effort: None, summary: None }) would serialize as "reasoning": {} — but this shape can't actually be produced by build_reasoning (it returns None whenever supports_reasoning_summaries=false).

---

6. Tool registration gates

Tools registered for any turn are computed in tools/src/tool_config.rs (ToolsConfig::new, lines 167-273) and core/src/tools/spec_plan.rs::collect_tool_executors. Catalog-controlled gates:

shell_type

tool_config.rs:204-225 collapses the catalog value into a runtime shell type, then spec_plan.rs:264-291 registers handlers:

| Catalog shell_type | After feature-flag adjustment | Handler registered |
|---|---|---|
| "default" | becomes ShellCommand | ShellCommandHandler |
| "local" | becomes ShellCommand | ShellCommandHandler |
| "shell_command" | ShellCommand | ShellCommandHandler |
| "unified_exec" (if UnifiedExec feature on and conpty_supported()) | UnifiedExec | ExecCommandHandler + WriteStdinHandler + secondary ShellCommandHandler (lines 296-307) |
| "unified_exec" (otherwise) | ShellCommand | ShellCommandHandler only |
| "disabled" | Disabled | nothing |

If feature ShellTool is off entirely, the type is forced to Disabled regardless of catalog. So shell_type: "disabled" is the only way to disable from the catalog; turning it on requires both catalog config and the feature flag.

apply_patch_tool_type

Only one enum variant exists: "freeform". The handler registers a freeform (custom) tool with lark grammar (apply_patch_spec.rs:9-27). Wire shape:

{
  "type": "custom",
  "name": "apply_patch",
  "description": "...",
  "format": { "type": "grammar", "syntax": "lark", "definition": "..." }
}

If apply_patch_tool_type: null, no apply_patch tool is registered (spec_plan.rs:339-343 short-circuits).

web_search_tool_type

Doesn't gate registration — that's controlled by feature flags / runtime web_search_mode. It only shapes the wire web_search tool entry. Wire shape (tool_spec.rs:37-48):

{
  "type": "web_search",
  "external_web_access": true,
  "search_content_types": ["text", "image"]  // only if "text_and_image"
}

supports_search_tool

Gates tool_search registration alongside the Feature::ToolSearch flag (tool_config.rs:184-185):

let include_search_tool = model_info.supports_search_tool && features.enabled(Feature::ToolSearch);

Wire shape:

{
  "type": "tool_search",
  "execution": "...",
  "description": "...",
  "parameters": <JSON schema>
}

experimental_supported_tools

Only one string is read (spec_plan.rs:345-351):

if config.experimental_supported_tools.iter().any(|tool| tool == "test_sync_tool") {
    executors.push(Arc::new(TestSyncHandler));
}

Any other string is silently ignored. This is exhaustive — there are no other readers of this field outside tests as of the pinned commit.

input_modalities

contains(Image) enables image_generation tool registration if image_generation_tool_auth_allowed && Feature::ImageGeneration. Function: supports_image_generation (tool_config.rs:411-413). Wire shape:

{ "type": "image_generation", "output_format": "..." }

supports_image_detail_original

Doesn't add or remove tools; gates whether view_image and image inputs accept detail: "original". False (default) → values are normalized to auto (image_detail.rs:23-38).

Tools always registered regardless of catalog

From spec_plan.rs:

  • PlanHandler (line 315) — plan tool
  • RequestUserInputHandler (line 322) — gated by request_user_input_available_modes, not catalog
  • ViewImageHandler (line 356) — gated by environment_mode.has_environment(), not catalog
  • MCP handlers (ListMcpResourcesHandler, etc., lines 309-313) — gated by params.mcp_tools.is_some()
  • Dynamic tools and extension tools (lines 430-442)

Goal tools, multi-agent tools, code-mode tools, agent-jobs tools, permissions tools, image generation, and tool_search are all gated by feature flags that the catalog does not control.

Tool wire-shape summary (tools/src/tool_spec.rs:15-51)

| ToolSpec variant | Wire type | Distinguishing fields |
|---|---|---|
| Function | "function" | name, description, parameters, strict |
| Namespace | "namespace" | name, description, tools (nested) |
| ToolSearch | "tool_search" | execution, description, parameters |
| ImageGeneration | "image_generation" | output_format |
| WebSearch | "web_search" | external_web_access, filters, user_location, search_context_size, search_content_types (all skip-if-none) |
| Freeform | "custom" | name, description, format: {type, syntax, definition} |

---

7. Enum reference

All enums apply #[serde(rename_all = ...)]. Accepted strings on the wire and in catalog JSON:

ReasoningEffortlowercase (openai_models.rs:44)

"none" | "minimal" | "low" | "medium" | "high" | "xhigh"

Default: "medium".

ReasoningSummarylowercase (config_types.rs:32)

"auto" | "concise" | "detailed" | "none"

Default: "auto". "none" causes summary to be omitted on the wire.

Verbositylowercase (config_types.rs:59)

"low" | "medium" | "high"

Default: "medium".

Personalitylowercase (config_types.rs:278)

"none" | "friendly" | "pragmatic"

Runtime only (not a catalog field; used to select instructions_variables.personality_*).

ServiceTierlowercase for serde (config_types.rs:415)

JSON values: "fast" | "flex".

On the wire, request_value() rewrites: Fast → "priority", Flex → "flex". (line 421-426). Wire spelling is different from catalog spelling. The catalog service_tiers[].id field is a free-form string, but supports_service_tier checks against ServiceTier::from_request_value, which accepts "fast" | "priority" | "flex".

ModelVisibilitylowercase (openai_models.rs:171)

"list" | "hide" | "none"

ConfigShellToolTypesnake_case (openai_models.rs:194)

"default" | "local" | "unified_exec" | "disabled" | "shell_command"

ApplyPatchToolTypesnake_case (openai_models.rs:204)

"freeform"

Only one variant.

WebSearchToolTypesnake_case (openai_models.rs:212)

"text" | "text_and_image"

Default: "text".

TruncationModesnake_case (openai_models.rs:221)

"bytes" | "tokens"

InputModalitylowercase (openai_models.rs:80)

"text" | "image"

Default vec when omitted: ["text", "image"].

WebSearchUserLocationTypelowercase (config_types.rs:362)

"approximate"

Only one variant; only relevant via runtime web_search_config, not catalog.

WebSearchModelowercase (config_types.rs:289)

"disabled" | "cached" | "live"

Runtime only (not in the catalog).

WebSearchContextSizelowercase (config_types.rs:299)

"low" | "medium" | "high"

Runtime only.

---

8. Constraints that can't be changed without forking

Reach for a proxy-side transform or a fork when you need to alter any of these:

  • tool_choice: "auto" — hardcoded; no way to force "none" or a specific tool from config.
  • stream: true — hardcoded; Codex always uses streaming responses.
  • store: provider.is_azure_responses_endpoint() — only Azure providers set this true.
  • include: ["reasoning.encrypted_content"] — list contents are hardcoded; you cannot add/remove items via catalog.
  • prompt_cache_key — always set to the thread id; cannot be disabled.
  • client_metadata.x-codex-installation-id — always sent.
  • Reasoning omission semantics: Option<Reasoning> skips when supports_reasoning_summaries=false, but the Reasoning struct itself omits effort/summary independently via skip_serializing_if. You can't change this serialization.
  • Apply-patch grammar variant — only Freeform (lark) exists. There is no function-call variant of apply_patch available in this commit.
  • The "always-on" tools list (Plan, ViewImage where environment is present, RequestUserInput, MCP handlers when MCP is configured) is not catalog-gated.
  • prompt.md content — the bundled base instructions are included via include_str! in model_info.rs:16. They are used as the literal base_instructions for the fallback metadata, but for your catalog entries you supply the full string yourself.
  • Slug resolution rules (longest-prefix, single-segment namespace strip) — you can only influence them by choosing your catalog's slug values carefully.
  • Refresh behavior — when model_catalog_json is set, you get StaticModelsManager with no network refresh and no ETag handling. There is no opt-in to "use my catalog and refresh from upstream."
  • experimental_supported_tools — the only string that does anything is "test_sync_tool"; the field is effectively useless for anything else without a fork.
  • Personality enum values — fixed set of three. Templates substitute one of these or empty string.

---

9. Minimum-viable catalog entry

Every "required key" must appear, even if its value is null. This is the smallest object that successfully deserializes against ModelInfo, derived from the working test fixture at openai_models.rs:782-810 and what is and isn't #[serde(default)]:

{
  "models": [
    {
      "slug": "my-model",
      "display_name": "My Model",
      "description": null,
      "supported_reasoning_levels": [],
      "shell_type": "shell_command",
      "visibility": "list",
      "supported_in_api": true,
      "priority": 1,
      "availability_nux": null,
      "upgrade": null,
      "base_instructions": "You are a helpful coding agent.",
      "supports_reasoning_summaries": false,
      "support_verbosity": false,
      "default_verbosity": null,
      "apply_patch_tool_type": null,
      "truncation_policy": { "mode": "bytes", "limit": 10000 },
      "supports_parallel_tool_calls": false,
      "experimental_supported_tools": []
    }
  ]
}

Why each field is here:

| Field | Reason |
|---|---|
| slug | Required; lookup key + wire model. |
| display_name | Required (no #[serde(default)]). |
| description | Key required; null allowed. |
| supported_reasoning_levels | Required; empty vec acceptable. |
| shell_type | Required enum. "shell_command" registers the plain shell handler. |
| visibility | Required enum. "list" shows it in the picker. |
| supported_in_api | Required bool. |
| priority | Required i32. |
| availability_nux | Key required; null allowed. |
| upgrade | Key required; null allowed. |
| base_instructions | Required string. Sent as the wire instructions field. |
| supports_reasoning_summaries | Required bool; controls whether reasoning is on the wire. |
| support_verbosity | Required bool. |
| default_verbosity | Key required; null allowed. |
| apply_patch_tool_type | Key required; null allowed (no apply_patch tool). |
| truncation_policy | Required struct. |
| supports_parallel_tool_calls | Required bool. Becomes wire parallel_tool_calls. |
| experimental_supported_tools | Required vec; empty is fine. |

The following are omitted because they have #[serde(default)] annotations and pick up sensible defaults: default_reasoning_level (None), additional_speed_tiers ([]), service_tiers ([]), model_messages (None), default_reasoning_summary ("auto"), web_search_tool_type ("text"), supports_image_detail_original (false), context_window (None), max_context_window (None), auto_compact_token_limit (None), effective_context_window_percent (95), input_modalities (["text","image"]), supports_search_tool (false).

If you remove any field from the minimum list above (other than the default-annotated ones), serde_json will reject the file with "missing field …" and Codex will refuse to start.