diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index c13b26cd1..6947e2493 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -490,9 +490,21 @@ fn convert_message_to_openai( Ok(result) } -/// 清理 JSON schema(移除不支持的 format) -pub fn clean_schema(mut schema: Value) -> Value { +/// 清理工具参数的 JSON schema,并为根 schema 补齐 OpenAI 要求的 object 类型。 +pub fn clean_schema(schema: Value) -> Value { + clean_schema_inner(schema, true) +} + +fn clean_schema_inner(mut schema: Value, is_root: bool) -> Value { if let Some(obj) = schema.as_object_mut() { + let missing_type = is_root && !obj.contains_key("type"); + if missing_type { + obj.insert("type".to_string(), json!("object")); + } + if missing_type && !obj.contains_key("properties") { + obj.insert("properties".to_string(), json!({})); + } + // 移除 "format": "uri" if obj.get("format").and_then(|v| v.as_str()) == Some("uri") { obj.remove("format"); @@ -501,12 +513,12 @@ pub fn clean_schema(mut schema: Value) -> Value { // 递归清理嵌套 schema if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) { for (_, value) in properties.iter_mut() { - *value = clean_schema(value.clone()); + *value = clean_schema_inner(value.clone(), false); } } if let Some(items) = obj.get_mut("items") { - *items = clean_schema(items.clone()); + *items = clean_schema_inner(items.clone(), false); } } schema @@ -831,6 +843,75 @@ mod tests { let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); + assert_eq!( + result["tools"][0]["function"]["parameters"]["type"], + json!("object") + ); + assert_eq!( + result["tools"][0]["function"]["parameters"]["properties"]["location"]["type"], + json!("string") + ); + } + + #[test] + fn test_anthropic_to_openai_defaults_missing_tool_schema_type() { + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "What's the weather?"}], + "tools": [{ + "name": "get_weather", + "description": "Get weather info", + "input_schema": {"properties": {"location": {"type": "string"}}} + }] + }); + + let result = anthropic_to_openai(input).unwrap(); + let parameters = &result["tools"][0]["function"]["parameters"]; + assert_eq!(parameters["type"], json!("object")); + assert_eq!( + parameters["properties"]["location"]["type"], + json!("string") + ); + } + + #[test] + fn test_anthropic_to_openai_defaults_empty_tool_schema() { + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Do work"}], + "tools": [{"name": "do_work", "input_schema": {}}] + }); + + let result = anthropic_to_openai(input).unwrap(); + let parameters = &result["tools"][0]["function"]["parameters"]; + assert_eq!(parameters, &json!({"type": "object", "properties": {}})); + } + + #[test] + fn test_clean_schema_only_defaults_root_to_object() { + let schema = json!({ + "properties": { + "nullable_value": { + "anyOf": [{"type": "string"}, {"type": "null"}] + }, + "list": { + "items": {"type": "string"} + } + } + }); + + let result = clean_schema(schema); + assert_eq!(result["type"], json!("object")); + assert_eq!( + result["properties"]["nullable_value"], + json!({"anyOf": [{"type": "string"}, {"type": "null"}]}) + ); + assert_eq!( + result["properties"]["list"], + json!({"items": {"type": "string"}}) + ); } #[test] diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index d7379cd6e..e11d3a317 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -770,10 +770,51 @@ mod tests { assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["name"], "get_weather"); assert!(result["tools"][0].get("parameters").is_some()); + assert_eq!(result["tools"][0]["parameters"]["type"], json!("object")); + assert_eq!( + result["tools"][0]["parameters"]["properties"]["location"]["type"], + json!("string") + ); // input_schema should not appear assert!(result["tools"][0].get("input_schema").is_none()); } + #[test] + fn test_anthropic_to_responses_defaults_missing_tool_schema_type() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Weather?"}], + "tools": [{ + "name": "get_weather", + "description": "Get weather info", + "input_schema": {"properties": {"location": {"type": "string"}}} + }] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + let parameters = &result["tools"][0]["parameters"]; + assert_eq!(parameters["type"], json!("object")); + assert_eq!( + parameters["properties"]["location"]["type"], + json!("string") + ); + } + + #[test] + fn test_anthropic_to_responses_defaults_empty_tool_schema() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Do work"}], + "tools": [{"name": "do_work", "input_schema": {}}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + let parameters = &result["tools"][0]["parameters"]; + assert_eq!(parameters, &json!({"type": "object", "properties": {}})); + } + #[test] fn test_anthropic_to_responses_tool_choice_any_to_required() { let input = json!({ diff --git a/src/components/providers/forms/hooks/useApiKeyState.ts b/src/components/providers/forms/hooks/useApiKeyState.ts index 8b4c87626..1ccdb3800 100644 --- a/src/components/providers/forms/hooks/useApiKeyState.ts +++ b/src/components/providers/forms/hooks/useApiKeyState.ts @@ -60,15 +60,12 @@ export function useApiKeyState({ initialConfig || "{}", key.trim(), { - // 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段 - // - 新增模式:selectedPresetId !== null - // - 非官方类别:category !== undefined && category !== "official" + // 最佳实践:仅在"非官方/非云厂商类别"时补齐缺失字段 // - 官方类别:不创建字段(UI 也会禁用输入框) - // - 未传入 category:不创建字段(避免意外行为) + // - 云厂商类别:通常使用专用鉴权字段,不自动创建 Anthropic key + // - 未传入 category:按历史导入/自定义 provider 处理,允许补齐 createIfMissing: - selectedPresetId !== null && - category !== undefined && - category !== "official", + category !== "official" && category !== "cloud_provider", appType, apiKeyField, }, @@ -90,10 +87,13 @@ export function useApiKeyState({ (config: string, isEditMode: boolean) => { return ( selectedPresetId !== null || + (isEditMode && + category !== "official" && + category !== "cloud_provider") || (isEditMode && hasApiKeyField(config, appType)) ); }, - [selectedPresetId, appType], + [selectedPresetId, category, appType], ); return { diff --git a/tests/hooks/useApiKeyState.test.tsx b/tests/hooks/useApiKeyState.test.tsx new file mode 100644 index 000000000..791e1f199 --- /dev/null +++ b/tests/hooks/useApiKeyState.test.tsx @@ -0,0 +1,67 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useApiKeyState } from "@/components/providers/forms/hooks/useApiKeyState"; + +describe("useApiKeyState", () => { + it("shows and creates Claude API key for uncategorized edit providers", () => { + const onConfigChange = vi.fn(); + const initialConfig = JSON.stringify({ env: {} }, null, 2); + + const { result } = renderHook(() => + useApiKeyState({ + initialConfig, + onConfigChange, + selectedPresetId: null, + category: undefined, + appType: "claude", + }), + ); + + expect(result.current.showApiKey(initialConfig, true)).toBe(true); + + act(() => { + result.current.handleApiKeyChange("sk-test"); + }); + + const updated = JSON.parse(onConfigChange.mock.calls.at(-1)?.[0]); + expect(updated.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-test"); + }); + + it("keeps official and cloud provider edit behavior conservative", () => { + const initialConfig = JSON.stringify({ env: {} }, null, 2); + const officialConfigChange = vi.fn(); + + const official = renderHook(() => + useApiKeyState({ + initialConfig, + onConfigChange: officialConfigChange, + selectedPresetId: null, + category: "official", + appType: "claude", + }), + ); + expect(official.result.current.showApiKey(initialConfig, true)).toBe(false); + act(() => { + official.result.current.handleApiKeyChange("sk-official"); + }); + expect(officialConfigChange).toHaveBeenLastCalledWith(initialConfig); + + const cloudProviderConfigChange = vi.fn(); + const cloudProvider = renderHook(() => + useApiKeyState({ + initialConfig, + onConfigChange: cloudProviderConfigChange, + selectedPresetId: null, + category: "cloud_provider", + appType: "claude", + }), + ); + expect(cloudProvider.result.current.showApiKey(initialConfig, true)).toBe( + false, + ); + act(() => { + cloudProvider.result.current.handleApiKeyChange("sk-cloud"); + }); + expect(cloudProviderConfigChange).toHaveBeenLastCalledWith(initialConfig); + }); +});