Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ for deployment examples.
| xAI | [`configs/model-families/x-ai.toml`](configs/model-families/x-ai.toml) | `grok-4.3`, `grok-build-0.1` |
| Xiaomi | [`configs/model-families/xiaomi.toml`](configs/model-families/xiaomi.toml) | `mimo-v2.5`, `mimo-v2.5-pro` |
| Z.ai | [`configs/model-families/z-ai.toml`](configs/model-families/z-ai.toml) | `glm-5`, `glm-5.1`, `glm-5.2` |
| Tencent Hunyuan 3 (Hy3) | [`configs/model-families/hy3.toml`](configs/model-families/hy3.toml) | `hy3`, `hy3:free`, `hicap/hy3`, `hicap/hy3:free`, `tencent/hy3`, `tencent/hy3:free` |

## More Docs

Expand Down
1 change: 1 addition & 0 deletions codex-warp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ model_family_include = [
"configs/model-families/x-ai.toml",
"configs/model-families/xiaomi.toml",
"configs/model-families/z-ai.toml",
"configs/model-families/hy3.toml",
]
tool_policy_include = [
"configs/tool-policies/github.toml",
Expand Down
72 changes: 72 additions & 0 deletions configs/model-families/hy3.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Hy3 (Tencent Hunyuan 3) model-family compatibility.
#
# Hy3 is a 295B MoE model (21B active) from the Tencent Hy Team with a 256K
# context window. It is an OpenAI-compatible /chat/completions model that
# supports hybrid thinking (reasoning) and native function/tool calling.
#
# Reasoning modes are `no_think` (direct), `low`, and `high`. Codex expresses
# effort as `none`/`low`/`medium`/`high`. This catalog advertises only
# `none`/`low`/`high` (it drops `medium`; Hy3 has no medium mode), so Codex
# selects from the advertised levels and will not normally request `medium`.
# If a client still sends `medium`, it is forwarded unchanged (no remap) since
# Hy3 has no medium mode to map to. `none` is mapped to `no_think` via
# `reasoning_effort_none_value` so the provider receives a valid mode.
# NOTE: `no_think` is the assumed Hy3 value and is not yet verified against the
# live gateway; confirm with a smoke test (see docs/live-testing.md).
#
# Hy3 understands standard function tools but not Codex's `custom` (apply_patch
# freeform) tool type, so those are coerced with `as_function` and reconstructed
# on the way back (handled by the shared transform path).

[model_families.hy3]
priority = 0
patterns = [
"hy3*",
"hy3:*",
"hicap/hy3*",
"tencent/hy3*",
"hunyuan3*",
"hunyuan-3*",
]

[model_families.hy3.model_metadata]
context_window = 256000
max_context_window = 262144
effective_context_window_percent = 95
input_modalities = ["text"]
supports_parallel_tool_calls = false
supported_reasoning_levels = ["none", "low", "high"]
default_reasoning_level = "high"
supports_reasoning_summaries = true
shell_type = "shell_command"
apply_patch_tool_type = "freeform"
supports_search_tool = false
include_skills_usage_instructions = true

[model_families.hy3.transform]
backend = "open_ai_chat"
unsupported_tool_types = ["custom"]
unsupported_tool_strategy = "as_function"
force_parallel_tool_calls = false
drop_empty_tool_choice = true
reasoning_effort_none_value = "no_think"

# Exact override entry. It intentionally omits [transform] and most
# [model_metadata] fields: family transforms/metadata merge cumulatively
# (see provider.rs / config.rs `apply_to`), so exact ids inherit everything
# from the broad `hy3` family above and this entry only pins an exact
# context_window. Keep it minimal; add distinct behavior here only when a
# specific exact id needs to diverge from the broad family.
[model_families.hy3_exact]
priority = 10
patterns = [
"hy3",
"hy3:free",
"hicap/hy3",
"hicap/hy3:free",
"tencent/hy3",
"tencent/hy3:free",
]

[model_families.hy3_exact.model_metadata]
context_window = 256000
3 changes: 3 additions & 0 deletions docs/model-family-catalogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Provider/gateway behavior belongs in
| xAI | [`x-ai.toml`](../configs/model-families/x-ai.toml) | `grok-4.3`, `grok-build-0.1` |
| Xiaomi | [`xiaomi.toml`](../configs/model-families/xiaomi.toml) | `mimo-v2.5`, `mimo-v2.5-pro` |
| Z.ai | [`z-ai.toml`](../configs/model-families/z-ai.toml) | `glm-5`, `glm-5.1`, `glm-5.2` |
| Tencent Hunyuan 3 (Hy3) | [`hy3.toml`](../configs/model-families/hy3.toml) | `hy3`, `hy3:free`, `hicap/hy3`, `hicap/hy3:free`, `tencent/hy3`, `tencent/hy3:free` |

The default catalogs live in
[`configs/model-families`](../configs/model-families) and are loaded by
Expand Down Expand Up @@ -66,6 +67,7 @@ transform behavior.
| Xiaomi | `mimo-v2.5-pro` | 1,000k | text | low, medium, high; default medium | provider/default | Gateway profile does not carry model-specific overrides. |
| Z.ai | `glm-5`, `glm-5.1` family | 200k | text | low, medium, high; default medium | provider/default | Broad GLM-5 transform converts `reasoning.effort` to `thinking.type`; forces `parallel_tool_calls = false`. |
| Z.ai | `glm-5.2` | 1,000k | text | low, medium, high; default medium | provider/default | Inherits broad GLM-5 transform. |
| Tencent Hy3 | `hy3`, `hy3:free`, `hicap/hy3`, `hicap/hy3:free`, `tencent/hy3`, `tencent/hy3:free` | 256k | text | none, low, high; default high | provider/default | 256K MoE model with hybrid thinking. Drops `medium` from advertised levels and maps `none` -> `no_think` via `reasoning_effort_none_value`; coerces Codex `custom` tools to functions (`as_function`); forces `parallel_tool_calls = false`. Reasoning is surfaced from provider `reasoning_details`/`reasoning_content`/`reasoning` fields. |

## Matching And Priority

Expand Down Expand Up @@ -173,6 +175,7 @@ Transforms let a model entry override or adjust request translation.
| `append_responses_request_morphs` | Append native Responses morphs after removals. |
| `unsupported_tool_types` | Tool types to rewrite, drop, or pass through. |
| `unsupported_tool_strategy` | `drop`, `as_function`, or `passthrough`. |
| `reasoning_effort_none_value` | Optional value to send when Codex requests `none` reasoning effort (e.g. `no_think` for Hy3), so the provider receives a valid mode. |
| `drop_empty_tool_choice` | Whether to avoid forwarding empty/default tool choice. |
| `force_parallel_tool_calls` | Force `parallel_tool_calls` to a boolean value. |
| `request_stream_options_include_usage` | Add `stream_options.include_usage = true` for streamed chat requests when the provider documents support and the caller did not set `stream_options`. |
Expand Down
12 changes: 9 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ pub struct ModelMetadataConfig {
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct ModelMetadataFields {
pub context_window: Option<i64>,
pub max_context_window: Option<i64>,
Expand Down Expand Up @@ -327,7 +327,7 @@ pub struct ModelMetadataFields {
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct ModelFamilyConfig {
pub priority: i32,
pub patterns: Vec<String>,
Expand All @@ -343,6 +343,7 @@ pub struct TransformConfig {
pub responses_request_morphs: Vec<RequestMorph>,
pub unsupported_tool_types: Vec<String>,
pub unsupported_tool_strategy: UnsupportedToolStrategy,
pub reasoning_effort_none_value: Option<String>,
pub drop_empty_tool_choice: bool,
pub force_parallel_tool_calls: Option<bool>,
pub request_stream_options_include_usage: bool,
Expand All @@ -357,6 +358,7 @@ impl Default for TransformConfig {
responses_request_morphs: Vec::new(),
unsupported_tool_types: vec!["custom".to_string()],
unsupported_tool_strategy: UnsupportedToolStrategy::AsFunction,
reasoning_effort_none_value: None,
drop_empty_tool_choice: true,
force_parallel_tool_calls: None,
request_stream_options_include_usage: false,
Expand All @@ -366,7 +368,7 @@ impl Default for TransformConfig {
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct TransformConfigPatch {
pub backend: Option<Backend>,
pub chat_request_morphs: Option<Vec<RequestMorph>>,
Expand All @@ -377,6 +379,7 @@ pub struct TransformConfigPatch {
pub append_responses_request_morphs: Vec<RequestMorph>,
pub unsupported_tool_types: Option<Vec<String>>,
pub unsupported_tool_strategy: Option<UnsupportedToolStrategy>,
pub reasoning_effort_none_value: Option<String>,
pub drop_empty_tool_choice: Option<bool>,
pub force_parallel_tool_calls: Option<bool>,
pub request_stream_options_include_usage: Option<bool>,
Expand Down Expand Up @@ -414,6 +417,9 @@ impl TransformConfigPatch {
if let Some(strategy) = self.unsupported_tool_strategy {
transform.unsupported_tool_strategy = strategy;
}
if let Some(value) = &self.reasoning_effort_none_value {
transform.reasoning_effort_none_value = Some(value.clone());
}
if let Some(drop_empty_tool_choice) = self.drop_empty_tool_choice {
transform.drop_empty_tool_choice = drop_empty_tool_choice;
}
Expand Down
89 changes: 89 additions & 0 deletions src/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ fn example_configs_parse_request_morphs() {
);
assert!(default_config.model_families.contains_key("z_ai_glm_5"));
assert!(default_config.model_families.contains_key("z_ai_glm_5_2"));
assert!(default_config.model_families.contains_key("hy3"));
assert!(default_config.model_families.contains_key("hy3_exact"));
assert!(provider_entries(&default_config).is_empty());
assert!(provider_entries(&generic_config).is_empty());
assert!(!generic_config.provider.is_enabled());
Expand Down Expand Up @@ -153,6 +155,44 @@ fn example_configs_parse_request_morphs() {
);
}

#[test]
fn hy3_family_advertises_context_reasoning_and_tool_transforms() {
let config = load_config_layers(&[]).expect("default parses");
let hy3_matches = matching_model_families(&config, "hicap/hy3:free");
let family = hy3_matches
.first()
.expect("hy3 family matches hicap/hy3:free");

assert_eq!(family.model_metadata.context_window, Some(256000));
assert_eq!(
family.model_metadata.default_reasoning_level.as_deref(),
Some("high")
);
assert_eq!(
family.model_metadata.supports_parallel_tool_calls,
Some(false)
);

let levels = family
.model_metadata
.supported_reasoning_levels
.as_ref()
.expect("supported reasoning levels present");
assert!(levels.iter().any(|level| level == "none"));
assert!(levels.iter().any(|level| level == "low"));
assert!(levels.iter().any(|level| level == "high"));
assert!(!levels.iter().any(|level| level == "medium"));

assert_eq!(
family.transform.reasoning_effort_none_value.as_deref(),
Some("no_think")
);
assert_eq!(
family.transform.unsupported_tool_strategy,
Some(UnsupportedToolStrategy::AsFunction)
);
}

#[test]
fn reusable_provider_profiles_leave_auto_review_to_model_families() {
for config_path in [
Expand Down Expand Up @@ -539,3 +579,52 @@ fn transform_patch_can_remove_inherited_morphs_before_appending() {
.any(|morph| morph.kind == RequestMorphKind::ThinkingType)
);
}

#[test]
fn hy3_patterns_do_not_overmatch_unrelated_hunyuan_models() {
let config = load_config_layers(&[]).expect("default parses");
// These should NOT match the hy3 family, which would otherwise inherit the
// none->no_think remap and as_function coercion.
for id in ["hunyuan-13b", "hunyuan-turbo-3b"] {
assert!(
!matching_model_families(&config, id)
.iter()
.any(|f| f.transform.reasoning_effort_none_value.is_some()),
"{id} should not match the hy3 family"
);
}
// Real Hy3 Hunyuan ids should still match.
for id in ["hunyuan-3", "hunyuan3", "hunyuan-3b"] {
assert!(
matching_model_families(&config, id)
.iter()
.any(|f| f.transform.reasoning_effort_none_value.is_some()),
"{id} should match the hy3 family"
);
}
}

#[test]
fn hy3_exact_ids_inherit_broad_family_transform() {
// Exact ids match both the broad `hy3` family (priority 0) and the exact
// `hy3_exact` family (priority 10). Because transforms merge cumulatively
// (provider.rs / config.rs `apply_to`), an exact id must still carry the
// broad family's `reasoning_effort_none_value` and `as_function` coercion.
let config = load_config_layers(&[]).expect("default parses");
for id in ["hy3", "hy3:free", "hicap/hy3:free", "tencent/hy3"] {
let mut transform = TransformConfig::default();
for family in matching_model_families(&config, id) {
family.transform.apply_to(&mut transform);
}
assert_eq!(
transform.reasoning_effort_none_value.as_deref(),
Some("no_think"),
"exact id {id} should inherit none->no_think from the broad hy3 family"
);
assert_eq!(
transform.unsupported_tool_strategy,
UnsupportedToolStrategy::AsFunction,
"exact id {id} should inherit the as_function coercion"
);
}
}
83 changes: 71 additions & 12 deletions src/response_codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ impl ChatAccum {
));
}
}
self.reasoning_text.push_str(reasoning);
self.reasoning_text.push_str(&reasoning);
events.push(sse(
"response.reasoning_summary_text.delta",
json!({
Expand Down Expand Up @@ -926,11 +926,59 @@ pub(crate) fn chat_json_to_responses_with_policy(
})
}

fn chat_reasoning_text(value: &Value) -> Option<&str> {
value
fn chat_reasoning_text(value: &Value) -> Option<String> {
if let Some(text) = value
.get("reasoning_content")
.or_else(|| value.get("reasoning"))
.and_then(Value::as_str)
{
return Some(text.to_string());
}
// OpenRouter and some gateways return reasoning as a `reasoning_details`
// array of objects such as {"type": "text", "text": "..."} or plain strings.
// Some providers also emit `reasoning_details` as a single string or object,
// or use object shapes like {"type": "reasoning.summary", "text": "..."} /
// {"summary": "..."}. Flatten the contiguous text so hybrid-thinking models
// (e.g. Hy3) surface their chain-of-thought to Codex, and never silently
// discard all reasoning when the field is not an array.
let details: Vec<&Value> = match value.get("reasoning_details") {
Some(Value::Array(arr)) => arr.iter().collect(),
Some(Value::String(s)) => return Some(s.clone()),
Some(Value::Object(obj)) => {
if let Some(text) = obj
.get("text")
.or_else(|| obj.get("summary"))
.or_else(|| obj.get("reasoning"))
.and_then(Value::as_str)
{
return Some(text.to_string());
}
return None;
}
_ => return None,
};
let mut combined = String::new();
for item in details {
match item {
Value::String(text) => combined.push_str(text),
Value::Object(obj) => {
if let Some(text) = obj
.get("text")
.or_else(|| obj.get("summary"))
.or_else(|| obj.get("reasoning"))
.and_then(Value::as_str)
{
combined.push_str(text);
}
}
_ => {}
}
}
if combined.is_empty() {
None
} else {
Some(combined)
}
}

pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value {
Expand Down Expand Up @@ -1110,15 +1158,26 @@ pub(crate) fn native_stream_debug_summary(value: &Value) -> Option<Value> {
}

fn custom_tool_input(arguments: &str) -> String {
serde_json::from_str::<Value>(arguments)
.ok()
.and_then(|value| {
value
.get("input")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| arguments.to_string())
match serde_json::from_str::<Value>(arguments) {
// The model returned a JSON-encoded string for the patch input.
Ok(Value::String(s)) => s,
Ok(Value::Object(obj)) => {
if let Some(input) = obj.get("input").and_then(Value::as_str) {
return input.to_string();
}
// No `input` key: if there is exactly one string field, treat it as
// the patch input rather than forwarding the whole JSON object
// (which would be an invalid apply_patch payload).
let string_values: Vec<&str> = obj.values().filter_map(Value::as_str).collect();
if string_values.len() == 1 {
return string_values[0].to_string();
}
arguments.to_string()
}
// Non-JSON or an unexpected JSON shape: pass the raw arguments through
// as the input so the failure is visible rather than silently dropped.
_ => arguments.to_string(),
}
}

pub(crate) fn morph_native_sse_frame(
Expand Down
Loading