mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix: handle missing provider keys and tool schema types (#5069)
* fix: handle missing api keys and tool schema types * fix: preserve nested tool schemas --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -490,9 +490,21 @@ fn convert_message_to_openai(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清理 JSON schema(移除不支持的 format)
|
/// 清理工具参数的 JSON schema,并为根 schema 补齐 OpenAI 要求的 object 类型。
|
||||||
pub fn clean_schema(mut schema: Value) -> Value {
|
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() {
|
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"
|
// 移除 "format": "uri"
|
||||||
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
||||||
obj.remove("format");
|
obj.remove("format");
|
||||||
@@ -501,12 +513,12 @@ pub fn clean_schema(mut schema: Value) -> Value {
|
|||||||
// 递归清理嵌套 schema
|
// 递归清理嵌套 schema
|
||||||
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
||||||
for (_, value) in properties.iter_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") {
|
if let Some(items) = obj.get_mut("items") {
|
||||||
*items = clean_schema(items.clone());
|
*items = clean_schema_inner(items.clone(), false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
schema
|
schema
|
||||||
@@ -831,6 +843,75 @@ mod tests {
|
|||||||
let result = anthropic_to_openai(input).unwrap();
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
assert_eq!(result["tools"][0]["type"], "function");
|
assert_eq!(result["tools"][0]["type"], "function");
|
||||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -770,10 +770,51 @@ mod tests {
|
|||||||
assert_eq!(result["tools"][0]["type"], "function");
|
assert_eq!(result["tools"][0]["type"], "function");
|
||||||
assert_eq!(result["tools"][0]["name"], "get_weather");
|
assert_eq!(result["tools"][0]["name"], "get_weather");
|
||||||
assert!(result["tools"][0].get("parameters").is_some());
|
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
|
// input_schema should not appear
|
||||||
assert!(result["tools"][0].get("input_schema").is_none());
|
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]
|
#[test]
|
||||||
fn test_anthropic_to_responses_tool_choice_any_to_required() {
|
fn test_anthropic_to_responses_tool_choice_any_to_required() {
|
||||||
let input = json!({
|
let input = json!({
|
||||||
|
|||||||
@@ -60,15 +60,12 @@ export function useApiKeyState({
|
|||||||
initialConfig || "{}",
|
initialConfig || "{}",
|
||||||
key.trim(),
|
key.trim(),
|
||||||
{
|
{
|
||||||
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
// 最佳实践:仅在"非官方/非云厂商类别"时补齐缺失字段
|
||||||
// - 新增模式:selectedPresetId !== null
|
|
||||||
// - 非官方类别:category !== undefined && category !== "official"
|
|
||||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||||
// - 未传入 category:不创建字段(避免意外行为)
|
// - 云厂商类别:通常使用专用鉴权字段,不自动创建 Anthropic key
|
||||||
|
// - 未传入 category:按历史导入/自定义 provider 处理,允许补齐
|
||||||
createIfMissing:
|
createIfMissing:
|
||||||
selectedPresetId !== null &&
|
category !== "official" && category !== "cloud_provider",
|
||||||
category !== undefined &&
|
|
||||||
category !== "official",
|
|
||||||
appType,
|
appType,
|
||||||
apiKeyField,
|
apiKeyField,
|
||||||
},
|
},
|
||||||
@@ -90,10 +87,13 @@ export function useApiKeyState({
|
|||||||
(config: string, isEditMode: boolean) => {
|
(config: string, isEditMode: boolean) => {
|
||||||
return (
|
return (
|
||||||
selectedPresetId !== null ||
|
selectedPresetId !== null ||
|
||||||
|
(isEditMode &&
|
||||||
|
category !== "official" &&
|
||||||
|
category !== "cloud_provider") ||
|
||||||
(isEditMode && hasApiKeyField(config, appType))
|
(isEditMode && hasApiKeyField(config, appType))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[selectedPresetId, appType],
|
[selectedPresetId, category, appType],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user