Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector

# Conflicts:
#	src/components/providers/forms/ProviderForm.tsx
This commit is contained in:
SaladDay
2026-06-23 15:54:43 +00:00
91 changed files with 4342 additions and 626 deletions
+1 -1
View File
@@ -1350,7 +1350,7 @@ impl RequestForwarder {
.await;
if restored > 0 {
log::debug!(
"[Codex] Restored {restored} cached function call(s) for Chat upstream"
"[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream"
);
}
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
+325 -77
View File
@@ -145,6 +145,77 @@ pub fn normalize_anthropic_tool_thinking_history_for_provider(
normalize_anthropic_tool_thinking_history(body)
}
/// DeepSeek official Anthropic-compatible endpoint URL
const DEEPSEEK_OFFICIAL_ANTHROPIC_URL: &str = "https://api.deepseek.com/anthropic";
/// Check whether the provider is configured to use DeepSeek's official
/// Anthropic-compatible endpoint.
fn is_deepseek_official_anthropic_endpoint(provider: &Provider) -> bool {
let settings = &provider.settings_config;
let base_url = settings
.get("env")
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str())
.or_else(|| settings.get("base_url").and_then(|v| v.as_str()))
.or_else(|| settings.get("baseURL").and_then(|v| v.as_str()))
.or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str()));
base_url.map(|u| u.trim_end_matches('/')) == Some(DEEPSEEK_OFFICIAL_ANTHROPIC_URL)
}
/// DeepSeek's official Anthropic-compatible endpoint treats
/// `thinking: { type: "disabled" }` and effort parameters (`output_config.effort`
/// or `reasoning_effort`) as mutually exclusive, returning HTTP 400:
/// "thinking options type cannot be disabled when reasoning_effort is set".
/// This breaks Claude Code 2.1.166+ Workflow/Dynamic Workflow features.
///
/// Rather than overriding Claude Code's intentional `thinking: disabled` for
/// sub-agents, we respect that decision and remove the conflicting effort
/// parameters instead. `thinking: disabled` means "don't output thinking
/// blocks", which is the correct behavior for sub-agents that don't need
/// to display reasoning to the user.
///
/// <https://github.com/deepseek-ai/DeepSeek-V3/issues/1397>
pub fn normalize_deepseek_thinking_disabled_strip_effort(
body: &mut Value,
provider: &Provider,
) -> bool {
if !is_deepseek_official_anthropic_endpoint(provider) {
return false;
}
let thinking_type = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str());
if thinking_type != Some("disabled") {
return false;
}
let mut changed = false;
// Remove output_config.effort (Anthropic format)
if let Some(oc) = body
.get_mut("output_config")
.and_then(|v| v.as_object_mut())
{
changed |= oc.remove("effort").is_some();
// Clean up empty output_config
if oc.is_empty() {
body.as_object_mut().unwrap().remove("output_config");
}
}
// Remove reasoning_effort (OpenAI format, may be present in passthrough)
if body.get("reasoning_effort").is_some() {
body.as_object_mut().unwrap().remove("reasoning_effort");
changed = true;
}
changed
}
pub fn normalize_anthropic_messages_for_provider(
body: &mut Value,
provider: &Provider,
@@ -154,77 +225,12 @@ pub fn normalize_anthropic_messages_for_provider(
return false;
}
let mut changed = normalize_anthropic_system_role_messages(body);
changed |= normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
let mut changed =
normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
changed |= normalize_deepseek_thinking_disabled_strip_effort(body, provider);
changed
}
fn normalize_anthropic_system_role_messages(body: &mut Value) -> bool {
let mut system_parts = Vec::new();
let changed = {
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
return false;
};
let original_len = messages.len();
let mut kept_messages = Vec::with_capacity(messages.len());
for message in std::mem::take(messages) {
if message.get("role").and_then(Value::as_str) == Some("system") {
if let Some(content) = message.get("content") {
append_anthropic_system_parts(content, &mut system_parts);
}
} else {
kept_messages.push(message);
}
}
let changed = kept_messages.len() != original_len;
*messages = kept_messages;
changed
};
if !changed || system_parts.is_empty() {
return changed;
}
let mut merged_parts = Vec::new();
if let Some(existing) = body.get("system") {
append_anthropic_system_parts(existing, &mut merged_parts);
}
merged_parts.extend(system_parts);
if !merged_parts.is_empty() {
body["system"] = Value::Array(merged_parts);
}
true
}
fn append_anthropic_system_parts(content: &Value, parts: &mut Vec<Value>) {
match content {
Value::String(text) if !text.trim().is_empty() => {
parts.push(json!({
"type": "text",
"text": text
}));
}
Value::Array(items) => {
for item in items {
append_anthropic_system_parts(item, parts);
}
}
Value::Object(obj)
if obj
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.trim().is_empty()) =>
{
parts.push(Value::Object(obj.clone()));
}
_ => {}
}
}
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
return false;
@@ -2117,7 +2123,10 @@ mod tests {
}
#[test]
fn test_anthropic_system_role_messages_move_to_top_level_system() {
fn test_anthropic_messages_no_longer_hoists_system_role_messages() {
// After reverting #3775, role=system messages are left in `messages[]`
// (DeepSeek's endpoint accepts them natively) and the top-level `system`
// field is untouched, preserving the request prefix.
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
@@ -2139,15 +2148,13 @@ mod tests {
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
assert!(changed);
assert!(!changed);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
let system = body["system"].as_array().unwrap();
assert_eq!(system[0]["text"], "Existing top-level system.");
assert_eq!(system[1]["text"], "Message system one.");
assert_eq!(system[2]["text"], "Message system two.");
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[2]["role"], "system");
assert_eq!(body["system"], "Existing top-level system.");
}
#[test]
@@ -2300,4 +2307,245 @@ mod tests {
assert!(!changed);
assert_eq!(body, original);
}
// ==================== normalize_deepseek_thinking_disabled_strip_effort 测试 ====================
fn deepseek_official_provider() -> Provider {
create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
"ANTHROPIC_API_KEY": "test-key"
}
}))
}
#[test]
fn test_deepseek_official_strips_output_config_effort() {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"output_config": { "effort": "max" },
"max_tokens": 100000
});
let changed = normalize_deepseek_thinking_disabled_strip_effort(
&mut body,
&deepseek_official_provider(),
);
assert!(changed);
assert_eq!(body["thinking"]["type"], "disabled");
assert!(body.get("output_config").is_none());
}
#[test]
fn test_deepseek_official_strips_reasoning_effort() {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"reasoning_effort": "high",
"max_tokens": 100000
});
let changed = normalize_deepseek_thinking_disabled_strip_effort(
&mut body,
&deepseek_official_provider(),
);
assert!(changed);
assert_eq!(body["thinking"]["type"], "disabled");
assert!(body.get("reasoning_effort").is_none());
}
#[test]
fn test_deepseek_official_strips_both_effort_fields() {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"output_config": { "effort": "max" },
"reasoning_effort": "high",
"max_tokens": 100000
});
let changed = normalize_deepseek_thinking_disabled_strip_effort(
&mut body,
&deepseek_official_provider(),
);
assert!(changed);
assert_eq!(body["thinking"]["type"], "disabled");
assert!(body.get("output_config").is_none());
assert!(body.get("reasoning_effort").is_none());
}
#[test]
fn test_deepseek_official_no_effort_no_change() {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"max_tokens": 100000
});
let original = body.clone();
let changed = normalize_deepseek_thinking_disabled_strip_effort(
&mut body,
&deepseek_official_provider(),
);
assert!(!changed);
assert_eq!(body, original);
}
#[test]
fn test_deepseek_official_preserves_output_config_other_fields() {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"output_config": { "effort": "max", "temperature": 0.5 },
"max_tokens": 100000
});
let changed = normalize_deepseek_thinking_disabled_strip_effort(
&mut body,
&deepseek_official_provider(),
);
assert!(changed);
assert_eq!(body["output_config"]["temperature"], 0.5);
assert!(body["output_config"].get("effort").is_none());
}
#[test]
fn test_deepseek_official_non_disabled_not_modified() {
let cases = vec![
(
"enabled",
json!({ "type": "enabled", "budget_tokens": 16000 }),
),
("adaptive", json!({ "type": "adaptive" })),
];
for (label, thinking_value) in cases {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": thinking_value,
"output_config": { "effort": "max" },
"max_tokens": 100000
});
let original = body.clone();
let changed = normalize_deepseek_thinking_disabled_strip_effort(
&mut body,
&deepseek_official_provider(),
);
assert!(!changed, "should not modify thinking.type={label}");
assert_eq!(body, original);
}
// missing thinking field entirely
let mut body = json!({
"model": "deepseek-v4-pro",
"output_config": { "effort": "max" },
"max_tokens": 100000
});
let original = body.clone();
assert!(!normalize_deepseek_thinking_disabled_strip_effort(
&mut body,
&deepseek_official_provider()
));
assert_eq!(body, original);
}
#[test]
fn test_deepseek_official_url_with_trailing_slash() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/",
"ANTHROPIC_API_KEY": "test-key"
}
}));
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"output_config": { "effort": "max" },
"max_tokens": 100000
});
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
assert!(changed);
assert!(body.get("output_config").is_none());
}
#[test]
fn test_deepseek_official_detected_via_base_url_fallback() {
let provider = create_provider(json!({
"base_url": "https://api.deepseek.com/anthropic",
"ANTHROPIC_API_KEY": "test-key"
}));
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"reasoning_effort": "high",
"max_tokens": 100000
});
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
assert!(changed);
assert!(body.get("reasoning_effort").is_none());
}
#[test]
fn test_non_deepseek_endpoint_not_modified() {
let providers = vec![
create_provider(json!({
"env": { "ANTHROPIC_BASE_URL": "https://other-api.com/anthropic", "ANTHROPIC_API_KEY": "test-key" }
})),
create_provider(json!({
"env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "test-key" }
})),
];
for provider in providers {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"output_config": { "effort": "max" },
"max_tokens": 100000
});
let original = body.clone();
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
assert!(
!changed,
"should not modify for {}",
provider.settings_config["env"]["ANTHROPIC_BASE_URL"]
);
assert_eq!(body, original);
}
}
#[test]
fn test_normalize_messages_pipeline_strips_effort_for_deepseek() {
let mut body = json!({
"model": "deepseek-v4-pro",
"thinking": { "type": "disabled" },
"output_config": { "effort": "max" },
"max_tokens": 100000,
"messages": [{ "role": "user", "content": "hello" }]
});
let changed = normalize_anthropic_messages_for_provider(
&mut body,
&deepseek_official_provider(),
"anthropic",
);
assert!(changed);
assert_eq!(body["thinking"]["type"], "disabled");
assert!(body.get("output_config").is_none());
}
}
@@ -150,7 +150,7 @@ impl CodexChatHistoryStore {
Some(item_type) if is_call_item_type(item_type) => {
if let Some(call_id) = response_item_call_id(&item) {
if let Some(cached) = lookup.call(&call_id) {
if enrich_call_item_reasoning(&mut item, cached) {
if enrich_call_item_from_cache(&mut item, cached) {
enriched += 1;
}
}
@@ -466,17 +466,26 @@ fn is_call_output_item_type(item_type: &str) -> bool {
)
}
fn enrich_call_item_reasoning(item: &mut Value, cached: &Value) -> bool {
fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool {
let mut changed = false;
for key in ["reasoning_content", "reasoning"] {
for key in [
"name",
"namespace",
"arguments",
"input",
"status",
"execution",
"reasoning_content",
"reasoning",
] {
if item.get(key).is_some_and(|value| !is_empty_value(value)) {
continue;
}
let Some(reasoning) = cached.get(key).filter(|value| !is_empty_value(value)) else {
let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else {
continue;
};
if let Some(object) = item.as_object_mut() {
object.insert(key.to_string(), reasoning.clone());
object.insert(key.to_string(), value.clone());
changed = true;
}
}
@@ -675,6 +684,48 @@ mod tests {
assert_eq!(input.len(), 2);
}
#[tokio::test]
async fn enriches_existing_function_call_missing_name_and_arguments() {
let history = CodexChatHistoryStore::default();
history
.record_response(&json!({
"id": "resp_1",
"output": [
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}",
"reasoning_content": "Need to inspect the file."
}
]
}))
.await;
let mut request = json!({
"previous_response_id": "resp_1",
"input": [
{
"type": "function_call",
"call_id": "call_1"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "ok"
}
]
});
assert_eq!(history.enrich_request(&mut request).await, 1);
let input = request["input"].as_array().unwrap();
assert_eq!(input[0]["type"], "function_call");
assert_eq!(input[0]["name"], "read_file");
assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}");
assert_eq!(input[0]["reasoning_content"], "Need to inspect the file.");
assert_eq!(input[1]["type"], "function_call_output");
}
#[tokio::test]
async fn restores_parallel_tool_calls_as_one_assistant_group() {
let history = CodexChatHistoryStore::default();
@@ -429,8 +429,10 @@ impl ChatToResponsesState {
if let Some(id) = id_delta {
state.call_id = id;
}
if let Some(name) = name_delta {
state.name = name;
if let Some(ref name) = name_delta {
if !name.is_empty() {
state.name.clone_from(name);
}
}
if !args_delta.is_empty() {
state.arguments.push_str(&args_delta);
@@ -442,7 +444,7 @@ impl ChatToResponsesState {
}
}
if !state.added && (!state.call_id.is_empty() || !state.name.is_empty()) {
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
should_add = true;
pending_arguments = state.arguments.clone();
} else if state.added {
@@ -464,9 +466,6 @@ impl ChatToResponsesState {
if state.call_id.is_empty() {
state.call_id = format!("call_{chat_index}");
}
if state.name.is_empty() {
state.name = "unknown_tool".to_string();
}
state.output_index = Some(assigned);
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
state.item_id = response_tool_call_item_id_from_chat_name(
@@ -699,6 +698,21 @@ impl ChatToResponsesState {
continue;
}
// Skip tool calls with missing names (defensive: some models generate
// tool call deltas without providing a valid function name)
let has_bad_name = self
.tools
.get(&key)
.map(|state| state.name.is_empty())
.unwrap_or(true);
if has_bad_name {
if let Some(state) = self.tools.get_mut(&key) {
state.done = true;
}
log::warn!("[Codex] Skipping streaming tool call with missing name");
continue;
}
if self
.tools
.get(&key)
@@ -713,9 +727,6 @@ impl ChatToResponsesState {
if state.call_id.is_empty() {
state.call_id = format!("call_{key}");
}
if state.name.is_empty() {
state.name = "unknown_tool".to_string();
}
state.output_index = Some(assigned);
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
@@ -1398,6 +1398,14 @@ fn chat_tool_calls_to_response_output_items(
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
for (index, tool_call) in tool_calls.iter().enumerate() {
// Skip tool calls with missing function names (defensive: some models
// may generate tool calls without providing a valid name)
let function = tool_call.get("function").unwrap_or(&Value::Null);
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() {
log::warn!("[Codex] Skipping tool call with missing name");
continue;
}
output.push(chat_tool_call_to_response_item(
tool_call,
index,
@@ -1406,11 +1414,11 @@ fn chat_tool_calls_to_response_output_items(
));
}
} else if let Some(function_call) = message.get("function_call") {
output.push(chat_legacy_function_call_to_response_item(
function_call,
reasoning,
tool_context,
));
if let Some(item) =
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
{
output.push(item);
}
}
output
@@ -1448,7 +1456,7 @@ fn chat_legacy_function_call_to_response_item(
function_call: &Value,
reasoning: Option<&str>,
tool_context: &CodexToolContext,
) -> Value {
) -> Option<Value> {
let call_id = function_call
.get("id")
.and_then(|v| v.as_str())
@@ -1458,10 +1466,18 @@ fn chat_legacy_function_call_to_response_item(
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
// Skip legacy function calls with missing names (defensive: some models
// may generate function_call without providing a valid name)
if name.is_empty() {
log::warn!("[Codex] Skipping legacy function_call with missing name");
return None;
}
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
response_tool_call_item_from_chat_name(
Some(response_tool_call_item_from_chat_name(
&item_id,
"completed",
call_id,
@@ -1469,7 +1485,7 @@ fn chat_legacy_function_call_to_response_item(
&arguments,
reasoning,
tool_context,
)
))
}
pub(crate) fn response_tool_call_item_id_from_chat_name(