mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
Merge origin/main into dev
This commit is contained in:
@@ -165,6 +165,7 @@ mod tests {
|
||||
convert_openai_chat_request_to_claude_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
|
||||
@@ -219,6 +220,43 @@ mod tests {
|
||||
assert_eq!(converted["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_request_to_chat_clamps_max_reasoning_effort_to_high() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"thinking": {"type": "enabled", "budget_tokens": 1024},
|
||||
"output_config": {"effort": "max"},
|
||||
"max_tokens": 128,
|
||||
});
|
||||
|
||||
let converted =
|
||||
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
|
||||
|
||||
assert_eq!(converted["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_request_to_chat_clamps_xhigh_reasoning_effort_to_high() {
|
||||
let body = json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{"text": "hello"}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"thinkingConfig": {"thinkingBudget": 8192}
|
||||
}
|
||||
});
|
||||
|
||||
let converted = normalize_gemini_request_to_openai_chat_request(
|
||||
&body,
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
)
|
||||
.expect("openai chat request");
|
||||
|
||||
assert_eq!(converted["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_normalizer_keeps_tool_history_chat_safe() {
|
||||
let call_id_one = "call_weather_123";
|
||||
@@ -333,6 +371,34 @@ mod tests {
|
||||
assert_eq!(messages[0]["content"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_normalizer_clamps_chat_reasoning_effort_and_filters_extensions() {
|
||||
let body = json!({
|
||||
"model": "gpt-5.1",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "xhigh"},
|
||||
"text": {"verbosity": "high"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"store": false,
|
||||
"service_tier": "priority",
|
||||
"prompt_cache_key": "cache_123",
|
||||
"safety_identifier": "user_123"
|
||||
});
|
||||
|
||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&body)
|
||||
.expect("openai chat request");
|
||||
|
||||
assert_eq!(converted["reasoning_effort"], "high");
|
||||
assert_eq!(converted["verbosity"], "high");
|
||||
assert_eq!(converted["service_tier"], "priority");
|
||||
assert_eq!(converted["prompt_cache_key"], "cache_123");
|
||||
assert_eq!(converted["safety_identifier"], "user_123");
|
||||
assert!(converted.get("include").is_none());
|
||||
assert!(converted.get("store").is_none());
|
||||
assert!(converted.get("text").is_none());
|
||||
assert!(converted.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_normalizer_preserves_multiple_claude_tool_results() {
|
||||
let body = json!({
|
||||
|
||||
@@ -193,6 +193,7 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
|
||||
.and_then(|value| value.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.and_then(openai_chat_reasoning_effort)
|
||||
{
|
||||
output.insert(
|
||||
"reasoning_effort".to_string(),
|
||||
@@ -205,12 +206,12 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
|
||||
"openai",
|
||||
&output,
|
||||
));
|
||||
output.extend(chat_compatible_responses_extension_object(
|
||||
output.extend(chat_compatible_openai_responses_extension_object(
|
||||
&canonical.extensions,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
&output,
|
||||
));
|
||||
output.extend(chat_compatible_responses_extension_object(
|
||||
output.extend(chat_compatible_openai_responses_extension_object(
|
||||
&canonical.extensions,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
&output,
|
||||
@@ -218,34 +219,29 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
fn chat_compatible_responses_extension_object(
|
||||
fn openai_chat_reasoning_effort(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" | "xhigh" | "max" => Some("high"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_compatible_openai_responses_extension_object(
|
||||
extensions: &std::collections::BTreeMap<String, Value>,
|
||||
namespace: &str,
|
||||
existing: &Map<String, Value>,
|
||||
) -> Map<String, Value> {
|
||||
const CHAT_COMPATIBLE_RESPONSES_FIELDS: &[&str] = &[
|
||||
"stream",
|
||||
"stream_options",
|
||||
"verbosity",
|
||||
"store",
|
||||
"service_tier",
|
||||
"safety_identifier",
|
||||
"prompt_cache_key",
|
||||
];
|
||||
extensions
|
||||
.get(namespace)
|
||||
.and_then(Value::as_object)
|
||||
.map(|object| {
|
||||
object
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
CHAT_COMPATIBLE_RESPONSES_FIELDS.contains(&key.as_str())
|
||||
&& !existing.contains_key(*key)
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect()
|
||||
namespace_extension_object(extensions, namespace, existing)
|
||||
.into_iter()
|
||||
.filter(|(key, _)| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"verbosity" | "service_tier" | "prompt_cache_key" | "safety_identifier" | "user"
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn force_stream_options(body: &mut Value, upstream_is_stream: bool) {
|
||||
|
||||
@@ -398,6 +398,23 @@ fn collect_codex_prompt_cache_control_anchors(value: &Value, anchors: &mut Vec<V
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_codex_cache_control_fields(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
object.remove("cache_control");
|
||||
for child in object.values_mut() {
|
||||
strip_codex_cache_control_fields(child);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for child in items {
|
||||
strip_codex_cache_control_fields(child);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_codex_prompt_cache_control_seed(provider_request_body: &Value) -> Option<String> {
|
||||
let mut anchors = Vec::new();
|
||||
collect_codex_prompt_cache_control_anchors(provider_request_body, &mut anchors);
|
||||
@@ -780,6 +797,7 @@ pub fn apply_codex_openai_responses_special_body_edits(
|
||||
inject_codex_default_variation_prompt(body_object);
|
||||
}
|
||||
|
||||
strip_codex_cache_control_fields(provider_request_body);
|
||||
insert_codex_prompt_cache_key(provider_request_body, prompt_cache_key);
|
||||
}
|
||||
|
||||
@@ -1206,6 +1224,49 @@ mod tests {
|
||||
|
||||
assert_eq!(body_a["prompt_cache_key"], body_b["prompt_cache_key"]);
|
||||
assert_ne!(body_a["prompt_cache_key"], body_c["prompt_cache_key"]);
|
||||
assert!(!body_a.to_string().contains("\"cache_control\""));
|
||||
assert!(!body_b.to_string().contains("\"cache_control\""));
|
||||
assert!(!body_c.to_string().contains("\"cache_control\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_body_edits_strip_developer_cache_control_before_upstream() {
|
||||
let mut provider_request_body = json!({
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "stable system brief",
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}, {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "new turn"}]
|
||||
}],
|
||||
"model": "gpt-5.4"
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some("key-a"),
|
||||
);
|
||||
|
||||
assert!(provider_request_body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|value| !value.trim().is_empty()));
|
||||
assert!(!provider_request_body
|
||||
.to_string()
|
||||
.contains("\"cache_control\""));
|
||||
assert_eq!(
|
||||
provider_request_body["input"][0]["content"][0]["text"],
|
||||
json!("stable system brief")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2416,7 +2416,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_claude_to_openai_chat_maps_max_output_effort_to_xhigh() {
|
||||
fn pure_claude_to_openai_chat_clamps_max_output_effort_to_high() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
@@ -2430,7 +2430,7 @@ mod tests {
|
||||
.expect("pure conversion should succeed")
|
||||
.value;
|
||||
|
||||
assert_eq!(converted["reasoning_effort"], "xhigh");
|
||||
assert_eq!(converted["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3256,7 +3256,7 @@ mod tests {
|
||||
)
|
||||
.expect("legacy conversion should still emit a chat body");
|
||||
|
||||
assert_eq!(converted["stream"], true);
|
||||
assert!(converted.get("stream").is_none());
|
||||
assert!(converted.get("include").is_none());
|
||||
assert!(converted.get("previous_response_id").is_none());
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@ impl ReasoningEffort {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Max => "xhigh",
|
||||
Self::XHigh | Self::Max => "high",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +534,7 @@ mod tests {
|
||||
"gpt-5.4-xhigh",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
|
||||
assert_eq!(openai_chat["reasoning_effort"], "high");
|
||||
|
||||
let mut responses = json!({
|
||||
"model": "gpt-5-upstream",
|
||||
@@ -608,7 +607,7 @@ mod tests {
|
||||
"gpt-5.4-fast-xhigh",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
|
||||
assert_eq!(openai_chat["reasoning_effort"], "high");
|
||||
assert_eq!(openai_chat["service_tier"], "priority");
|
||||
|
||||
let mut reversed = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
|
||||
|
||||
@@ -738,7 +738,7 @@ mod tests {
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "xhigh");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -98,7 +98,7 @@ Provider schema refresh is not a runtime dependency. Same-format runtime paths d
|
||||
| `reasoning.effort` | OpenAI enum | `reasoning_effort` | mapped; invalid enum blocked |
|
||||
| `reasoning.summary` | Responses-only | none | lossy-blocked |
|
||||
| `reasoning.budget_tokens` | Responses-only | none | lossy-blocked |
|
||||
| `stream` | Responses extension | `stream` | extension-preserved |
|
||||
| `stream` | Responses request transport policy | none | lossy-blocked; target stream policy is transport-owned |
|
||||
| `include` | Responses-only | none | lossy-blocked; legacy emitter no longer leaks |
|
||||
| `previous_response_id` | Responses-only | none | lossy-blocked; legacy emitter no longer leaks |
|
||||
| `truncation` | Responses-only | none | lossy-blocked |
|
||||
|
||||
@@ -379,7 +379,7 @@ Statuses used in this matrix: `native`, `mapped`, `mapped/lossy-blocked`, `exten
|
||||
| OpenAI | `CreateResponse` | `safety_identifier` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets |
|
||||
| OpenAI | `CreateResponse` | `service_tier` | 否 | `ServiceTier` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets |
|
||||
| OpenAI | `CreateResponse` | `store` | 否 | `boolean \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed |
|
||||
| OpenAI | `CreateResponse` | `stream` | 否 | `boolean \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed |
|
||||
| OpenAI | `CreateResponse` | `stream` | 否 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | target stream policy is transport-owned; cross-format conversion does not emit provider stream flags |
|
||||
| OpenAI | `CreateResponse` | `stream_options` | 否 | `ResponseStreamOptions` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent |
|
||||
| OpenAI | `CreateResponse` | `temperature` | 否 | `number \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed |
|
||||
| OpenAI | `CreateResponse` | `text` | 否 | `ResponseTextParam` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically |
|
||||
|
||||
@@ -95,7 +95,6 @@ OPENAI_RESPONSES_MAPPED = {
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"reasoning",
|
||||
"stream",
|
||||
"store",
|
||||
"service_tier",
|
||||
"safety_identifier",
|
||||
@@ -113,6 +112,7 @@ OPENAI_RESPONSES_BLOCKED = {
|
||||
"max_tool_calls",
|
||||
"user",
|
||||
"context_management",
|
||||
"stream",
|
||||
"stream_options",
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,178 @@ describe('Conversation stream compatibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders OpenAI Responses custom tool calls without text output', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.5',
|
||||
stream: true,
|
||||
input: 'Patch a file',
|
||||
}
|
||||
const toolInput = '*** Begin Patch\n*** Update File: demo.rs\n*** End Patch\n'
|
||||
const rawSse = [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_custom_123","object":"response","model":"gpt-5.5","status":"in_progress"}}',
|
||||
'',
|
||||
'event: response.output_item.added',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_123","type":"custom_tool_call","status":"in_progress","call_id":"call_123","input":"","name":"apply_patch"}}',
|
||||
'',
|
||||
'event: response.custom_tool_call_input.delta',
|
||||
'data: {"type":"response.custom_tool_call_input.delta","output_index":0,"item_id":"ctc_123","delta":"*** Begin Patch\\n"}',
|
||||
'',
|
||||
'event: response.custom_tool_call_input.delta',
|
||||
'data: {"type":"response.custom_tool_call_input.delta","output_index":0,"item_id":"ctc_123","delta":"*** Update File: demo.rs\\n*** End Patch\\n"}',
|
||||
'',
|
||||
'event: response.custom_tool_call_input.done',
|
||||
`data: ${JSON.stringify({ type: 'response.custom_tool_call_input.done', output_index: 0, item_id: 'ctc_123', input: toolInput })}`,
|
||||
'',
|
||||
'event: response.output_item.done',
|
||||
`data: ${JSON.stringify({ type: 'response.output_item.done', output_index: 0, item: { id: 'ctc_123', type: 'custom_tool_call', status: 'completed', call_id: 'call_123', input: toolInput, name: 'apply_patch' } })}`,
|
||||
'',
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_custom_123","object":"response","model":"gpt-5.5","status":"completed","output":[]}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const parsed = parseResponse(rawSse, requestBody, 'openai:responses')
|
||||
expect(parsed.messages[0]?.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
toolName: 'apply_patch',
|
||||
toolId: 'call_123',
|
||||
input: toolInput,
|
||||
})
|
||||
|
||||
const rendered = renderResponse(rawSse, requestBody, 'openai:responses')
|
||||
expect(rendered.error).toBeUndefined()
|
||||
expect(rendered.isStream).toBe(true)
|
||||
expect(rendered.blocks).toHaveLength(1)
|
||||
|
||||
const firstBlock = rendered.blocks[0]
|
||||
if (!firstBlock || firstBlock.type !== 'message') {
|
||||
throw new Error('expected first render block to be message')
|
||||
}
|
||||
|
||||
expect(firstBlock.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
toolName: 'apply_patch',
|
||||
toolId: 'call_123',
|
||||
input: toolInput,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps OpenAI Responses custom tool calls when text output is present', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.5',
|
||||
stream: true,
|
||||
input: 'Explain and patch',
|
||||
}
|
||||
const rawSse = [
|
||||
'event: response.output_text.delta',
|
||||
'data: {"type":"response.output_text.delta","delta":"I will patch it."}',
|
||||
'',
|
||||
'event: response.output_item.added',
|
||||
'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"ctc_456","type":"custom_tool_call","status":"in_progress","call_id":"call_456","input":"","name":"apply_patch"}}',
|
||||
'',
|
||||
'event: response.custom_tool_call_input.delta',
|
||||
'data: {"type":"response.custom_tool_call_input.delta","output_index":1,"item_id":"ctc_456","delta":"patch text"}',
|
||||
'',
|
||||
'event: response.output_item.done',
|
||||
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"ctc_456","type":"custom_tool_call","status":"completed","call_id":"call_456","input":"patch text","name":"apply_patch"}}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const rendered = renderResponse(rawSse, requestBody, 'openai:responses')
|
||||
const firstBlock = rendered.blocks[0]
|
||||
if (!firstBlock || firstBlock.type !== 'message') {
|
||||
throw new Error('expected first render block to be message')
|
||||
}
|
||||
|
||||
expect(firstBlock.content.map(block => block.type)).toEqual(['text', 'tool_use'])
|
||||
expect(firstBlock.content[1]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
toolName: 'apply_patch',
|
||||
input: 'patch text',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders future OpenAI Responses call items through the generic call fallback', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.5',
|
||||
stream: true,
|
||||
input: 'Run a command',
|
||||
}
|
||||
const action = { command: 'npm test', timeout_ms: 1000 }
|
||||
const expectedInput = JSON.stringify(action, null, 2)
|
||||
const rawSse = [
|
||||
'event: response.output_item.added',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"shell_123","type":"shell_call","status":"in_progress"}}',
|
||||
'',
|
||||
'event: response.output_item.done',
|
||||
`data: ${JSON.stringify({ type: 'response.output_item.done', output_index: 0, item: { id: 'shell_123', type: 'shell_call', status: 'completed', action } })}`,
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const parsed = parseResponse(rawSse, requestBody, 'openai:responses')
|
||||
expect(parsed.messages[0]?.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
toolName: 'shell_call',
|
||||
toolId: 'shell_123',
|
||||
input: expectedInput,
|
||||
})
|
||||
|
||||
const rendered = renderResponse(rawSse, requestBody, 'openai:responses')
|
||||
const firstBlock = rendered.blocks[0]
|
||||
if (!firstBlock || firstBlock.type !== 'message') {
|
||||
throw new Error('expected first render block to be message')
|
||||
}
|
||||
|
||||
expect(firstBlock.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
toolName: 'shell_call',
|
||||
toolId: 'shell_123',
|
||||
input: expectedInput,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps streamed function_call arguments when response.completed omits them', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.5',
|
||||
stream: true,
|
||||
input: 'What is the weather?',
|
||||
}
|
||||
const rawSse = [
|
||||
'event: response.created',
|
||||
`data: ${JSON.stringify({ type: 'response.created', response: { id: 'resp_fc_1', object: 'response', model: 'gpt-5.5', status: 'in_progress' } })}`,
|
||||
'',
|
||||
'event: response.output_item.added',
|
||||
`data: ${JSON.stringify({ type: 'response.output_item.added', output_index: 0, item: { id: 'fc_1', type: 'function_call', status: 'in_progress', call_id: 'call_1', name: 'get_weather', arguments: '' } })}`,
|
||||
'',
|
||||
'event: response.function_call_arguments.delta',
|
||||
`data: ${JSON.stringify({ type: 'response.function_call_arguments.delta', output_index: 0, item_id: 'fc_1', delta: '{"city":' })}`,
|
||||
'',
|
||||
'event: response.function_call_arguments.delta',
|
||||
`data: ${JSON.stringify({ type: 'response.function_call_arguments.delta', output_index: 0, item_id: 'fc_1', delta: '"SF"}' })}`,
|
||||
'',
|
||||
// 最终项故意不带 arguments:解析器不应用 '{}' 冲掉已收集的增量参数
|
||||
'event: response.completed',
|
||||
`data: ${JSON.stringify({ type: 'response.completed', response: { id: 'resp_fc_1', object: 'response', model: 'gpt-5.5', status: 'completed', output: [{ id: 'fc_1', type: 'function_call', status: 'completed', call_id: 'call_1', name: 'get_weather' }] } })}`,
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const parsed = parseResponse(rawSse, requestBody, 'openai:responses')
|
||||
// 命中同一 key,不重复渲染
|
||||
expect(parsed.messages).toHaveLength(1)
|
||||
expect(parsed.messages[0]?.content).toHaveLength(1)
|
||||
expect(parsed.messages[0]?.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
toolName: 'get_weather',
|
||||
toolId: 'call_1',
|
||||
input: '{"city":"SF"}',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders HTML-entity encoded OpenAI tool arguments as formatted JSON', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.4',
|
||||
|
||||
@@ -313,11 +313,11 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
return createMessage(role, contentBlocks)
|
||||
}
|
||||
|
||||
// function_call -> 工具调用
|
||||
if (itemType === 'function_call') {
|
||||
const toolId = String(item.call_id || item.id || '')
|
||||
const toolName = String(item.name || '')
|
||||
const args = String(item.arguments || '{}')
|
||||
// Responses API call item -> 工具调用
|
||||
if (this.isResponsesCallItemType(itemType)) {
|
||||
const toolId = this.responsesCallId(item)
|
||||
const toolName = this.responsesCallName(item)
|
||||
const args = this.responsesCallInput(item)
|
||||
return createMessage('assistant', [createToolUseBlock(toolId, toolName, args)])
|
||||
}
|
||||
|
||||
@@ -439,6 +439,14 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
if (contentBlocks.length > 0) {
|
||||
result.messages.push(createMessage('assistant', contentBlocks))
|
||||
}
|
||||
} else if (item && this.isResponsesCallItemType(item.type)) {
|
||||
result.messages.push(createMessage('assistant', [
|
||||
createToolUseBlock(
|
||||
this.responsesCallId(item),
|
||||
this.responsesCallName(item),
|
||||
this.responsesCallInput(item)
|
||||
),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,8 +574,39 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
|
||||
const textParts: string[] = []
|
||||
const toolCalls = new Map<string, { name: string; id: string; args: string[] }>()
|
||||
let currentToolId = ''
|
||||
let currentToolName = ''
|
||||
const outputIndexToToolKey = new Map<number, string>()
|
||||
let currentToolKey = ''
|
||||
|
||||
const ensureToolCall = (
|
||||
key: string,
|
||||
id: string,
|
||||
name: string,
|
||||
initialInput?: string
|
||||
) => {
|
||||
if (!key) return
|
||||
const existing = toolCalls.get(key)
|
||||
if (existing) {
|
||||
if (id) existing.id = id
|
||||
if (name) existing.name = name
|
||||
if (initialInput) existing.args = [initialInput]
|
||||
return
|
||||
}
|
||||
toolCalls.set(key, {
|
||||
name,
|
||||
id,
|
||||
args: initialInput ? [initialInput] : [],
|
||||
})
|
||||
}
|
||||
|
||||
const resolveToolKey = (chunk: RawObject): string => {
|
||||
const itemId = typeof chunk.item_id === 'string' ? chunk.item_id : ''
|
||||
if (itemId) return itemId
|
||||
const outputIndex = typeof chunk.output_index === 'number' ? chunk.output_index : null
|
||||
if (outputIndex != null) {
|
||||
return outputIndexToToolKey.get(outputIndex) || currentToolKey
|
||||
}
|
||||
return currentToolKey
|
||||
}
|
||||
|
||||
for (const rawChunk of chunks) {
|
||||
const chunk = rawChunk as RawObject
|
||||
@@ -596,28 +635,55 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理函数调用输出项添加: response.output_item.added
|
||||
if (eventType === 'response.output_item.added') {
|
||||
// 处理 Responses call 输出项添加/完成: response.output_item.added / done
|
||||
if (eventType === 'response.output_item.added' || eventType === 'response.output_item.done') {
|
||||
const item = chunk.item as RawObject | undefined
|
||||
if (item?.type === 'function_call') {
|
||||
currentToolId = String(item.call_id || item.id || '')
|
||||
currentToolName = String(item.name || '')
|
||||
if (currentToolId && !toolCalls.has(currentToolId)) {
|
||||
toolCalls.set(currentToolId, {
|
||||
name: currentToolName,
|
||||
id: currentToolId,
|
||||
args: [],
|
||||
})
|
||||
if (item && this.isResponsesCallItemType(item.type)) {
|
||||
const itemId = typeof item.id === 'string' ? item.id : ''
|
||||
const toolId = this.responsesCallId(item)
|
||||
const key = itemId || toolId || String(chunk.output_index ?? '')
|
||||
const input = eventType === 'response.output_item.done' && this.responsesCallHasInput(item)
|
||||
? this.responsesCallInput(item)
|
||||
: ''
|
||||
ensureToolCall(key, toolId, this.responsesCallName(item), input)
|
||||
currentToolKey = key
|
||||
if (typeof chunk.output_index === 'number') {
|
||||
outputIndexToToolKey.set(chunk.output_index, key)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理函数调用参数增量: response.function_call_arguments.delta
|
||||
if (eventType === 'response.function_call_arguments.delta') {
|
||||
// 处理已知 call 输入增量
|
||||
if (
|
||||
eventType === 'response.function_call_arguments.delta' ||
|
||||
eventType === 'response.custom_tool_call_input.delta'
|
||||
) {
|
||||
const delta = chunk.delta
|
||||
if (typeof delta === 'string' && currentToolId && toolCalls.has(currentToolId)) {
|
||||
toolCalls.get(currentToolId)?.args.push(delta)
|
||||
const key = resolveToolKey(chunk)
|
||||
if (typeof delta === 'string' && key && toolCalls.has(key)) {
|
||||
toolCalls.get(key)?.args.push(delta)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (eventType === 'response.function_call_arguments.done') {
|
||||
const key = resolveToolKey(chunk)
|
||||
const args = typeof chunk.arguments === 'string'
|
||||
? chunk.arguments
|
||||
: typeof chunk.delta === 'string'
|
||||
? chunk.delta
|
||||
: null
|
||||
if (key && toolCalls.has(key) && args != null) {
|
||||
toolCalls.get(key)!.args = [args]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (eventType === 'response.custom_tool_call_input.done') {
|
||||
const key = resolveToolKey(chunk)
|
||||
if (key && toolCalls.has(key) && typeof chunk.input === 'string') {
|
||||
toolCalls.get(key)!.args = [chunk.input]
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -630,17 +696,29 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
result.model = response.model
|
||||
}
|
||||
|
||||
// 从 output 中提取文本(备用方案)
|
||||
if (textParts.length === 0 && Array.isArray(response?.output)) {
|
||||
for (const rawItem of response.output as unknown[]) {
|
||||
const item = rawItem as RawObject
|
||||
if (item?.type === 'message' && Array.isArray(item?.content)) {
|
||||
// 从 output 中提取文本和工具调用(备用方案)
|
||||
if (Array.isArray(response?.output)) {
|
||||
const output = response.output as unknown[]
|
||||
for (let index = 0; index < output.length; index++) {
|
||||
const item = output[index] as RawObject
|
||||
if (textParts.length === 0 && item?.type === 'message' && Array.isArray(item?.content)) {
|
||||
for (const rawContent of item.content as unknown[]) {
|
||||
const content = rawContent as RawObject
|
||||
if (content?.type === 'output_text' && typeof content?.text === 'string') {
|
||||
textParts.push(content.text)
|
||||
}
|
||||
}
|
||||
} else if (this.isResponsesCallItemType(item.type)) {
|
||||
const itemId = typeof item.id === 'string' ? item.id : ''
|
||||
const toolId = this.responsesCallId(item)
|
||||
// 与流式阶段使用同一套 key 命中同一条工具调用,避免重复渲染
|
||||
const key = itemId || toolId || outputIndexToToolKey.get(index) || String(index)
|
||||
// 仅在最终项确实带有输入时才覆盖,避免用 '{}' 等默认值
|
||||
// 冲掉已通过增量事件收集到的参数
|
||||
const input = this.responsesCallHasInput(item)
|
||||
? this.responsesCallInput(item)
|
||||
: ''
|
||||
ensureToolCall(key, toolId, this.responsesCallName(item), input)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -731,6 +809,49 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
return createMessage(role, contentBlocks)
|
||||
}
|
||||
|
||||
private isResponsesCallItemType(itemType: unknown): boolean {
|
||||
return typeof itemType === 'string' && itemType.endsWith('_call')
|
||||
}
|
||||
|
||||
private responsesCallId(item: RawObject): string {
|
||||
return String(item.call_id || item.id || '')
|
||||
}
|
||||
|
||||
private responsesCallName(item: RawObject): string {
|
||||
const name = typeof item.name === 'string' ? item.name.trim() : ''
|
||||
if (name) return name
|
||||
return typeof item.type === 'string' ? item.type : 'tool_call'
|
||||
}
|
||||
|
||||
private responsesCallInputCandidate(item: RawObject): unknown {
|
||||
if (item.type === 'function_call') return item.arguments
|
||||
if (item.type === 'custom_tool_call') return item.input
|
||||
for (const key of ['input', 'arguments', 'action', 'query', 'code', 'prompt']) {
|
||||
if (item[key] != null) return item[key]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private responsesCallInput(item: RawObject): string {
|
||||
const input = this.responsesCallInputCandidate(item)
|
||||
if (typeof input === 'string') return input
|
||||
if (input == null) {
|
||||
if (item.type === 'function_call') return '{}'
|
||||
if (item.type === 'custom_tool_call') return ''
|
||||
return JSON.stringify(item, null, 2)
|
||||
}
|
||||
return JSON.stringify(input, null, 2)
|
||||
}
|
||||
|
||||
private responsesCallHasInput(item: RawObject): boolean {
|
||||
const input = this.responsesCallInputCandidate(item)
|
||||
if (input == null) {
|
||||
return item.type !== 'function_call' && item.type !== 'custom_tool_call'
|
||||
}
|
||||
if (typeof input === 'string') return input.length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射角色
|
||||
*/
|
||||
@@ -887,12 +1008,12 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
return createMessageBlock(role, contentBlocks, { roleLabel: this.getRoleLabel(role) })
|
||||
}
|
||||
|
||||
// function_call -> 工具调用
|
||||
if (itemType === 'function_call') {
|
||||
const toolName = String(item.name || '工具调用')
|
||||
const args = this.formatJson(item.arguments)
|
||||
// Responses API call item -> 工具调用
|
||||
if (this.isResponsesCallItemType(itemType)) {
|
||||
const toolName = this.responsesCallName(item)
|
||||
const args = this.formatJson(this.responsesCallInput(item))
|
||||
return createMessageBlock('assistant', [
|
||||
createToolUseRenderBlock(toolName, args, String(item.call_id || item.id || '')),
|
||||
createToolUseRenderBlock(toolName, args, this.responsesCallId(item)),
|
||||
], { roleLabel: 'Assistant', badges: [createBadgeBlock('工具调用', 'outline')] })
|
||||
}
|
||||
|
||||
@@ -1015,6 +1136,17 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
roleLabel: 'Assistant',
|
||||
}))
|
||||
}
|
||||
} else if (this.isResponsesCallItemType(item.type)) {
|
||||
blocks.push(createMessageBlock('assistant', [
|
||||
createToolUseRenderBlock(
|
||||
this.responsesCallName(item),
|
||||
this.formatJson(this.responsesCallInput(item)),
|
||||
this.responsesCallId(item)
|
||||
),
|
||||
], {
|
||||
roleLabel: 'Assistant',
|
||||
badges: [createBadgeBlock('工具调用', 'outline')],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user