mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 02:51:17 +08:00
feat(codex): support native Anthropic Messages protocol as upstream (#5071)
* feat(codex): support native Anthropic Messages protocol as upstream Allow gateways that only expose the native Anthropic Messages protocol (/v1/messages) to be used by Codex: the local proxy performs bidirectional request/response/streaming conversion between Responses and Anthropic. Backend: - Add two conversion modules: transform_codex_anthropic / streaming_codex_anthropic - codex.rs: add routing detection and auth: ANTHROPIC_AUTH_TOKEN→Bearer (default), ANTHROPIC_API_KEY→x-api-key, mutually exclusive - handlers.rs: add handle_codex_anthropic_to_responses_transform - forwarder.rs: support optional Claude Code client fingerprint impersonation (User-Agent / anthropic-beta / x-app / system prompt first-line injection) and /responses→/v1/messages rewriting - codex_config: the anthropic format reuses the NativeResponses profile to strip custom tools - ProviderMeta: add impersonateClaudeCode Frontend: - CodexApiFormat: add "anthropic"; the form adds auth field selection and an impersonation toggle - Add en/ja/zh/zh-TW copy Robustness: - Downgrade when tool history / forced tool_choice conflicts with extended thinking, avoiding upstream 400s - Emit cache_creation_input_tokens in usage and use saturating_add to guard against overflow - Append a unique suffix to non-streaming/streaming output-item ids to avoid multi-segment text/thinking overwriting each other * fix(codex): harden Anthropic bridge against empty text blocks and truncated streams - Drop empty/whitespace-only text content blocks when rebuilding Anthropic messages from Responses history; Anthropic 400s on empty text blocks (e.g. an empty assistant text emitted alongside a tool_use), which broke follow-up and tool-result requests. Also drop messages left without content. - Do not report a truncated Anthropic stream as completed: when the SSE connection ends before message_stop with no stop_reason, emit an incomplete response if partial output exists, or a failed (stream_truncated) response otherwise, mirroring the chat converter's EOF handling. * fix(codex): resolve Anthropic-bridge review findings Blocking: 1. Defer stripping the [1m] long-context marker until after catalog matching and model write-back, re-stripping it on the final Codex→Anthropic body and setting a flag to emit the context-1m-2025-08-07 beta header, so the marker is no longer lost or overridden by the default model. 2. Gate Anthropic thinking on the trailing turn only (via trailing_turn_allows_thinking) instead of scanning full history, so a Codex session that resends history each round no longer permanently loses thinking after the first tool call. 3. Inject 5m ephemeral cache_control on the Codex→Anthropic body by reusing cache_injector (handling the system string→array conversion), so system/tools/history are cached instead of re-sent at full price every round. 4. Add a shared base_url_is_full_endpoint helper (normalizing whitespace/query/fragment/trailing slash) used by both the Anthropic and Chat paths, so a base URL already ending in /v1/messages is treated as a full endpoint instead of double-appending to /v1/messages/v1/messages. 5. Align the catalog tool-profile predicate with the routing predicate so resolve_codex_catalog_tool_profile returns the Anthropic profile whenever the request converts to Anthropic, preventing freeform tools like apply_patch from being silently filtered by a ProxyChat catalog. 6. Explicitly disable native web_search for the Anthropic profile (including the no-catalog branch) via set_codex_native_web_search_field, so Codex no longer treats it as available while the transform silently drops it. 7. Only forward tool_choice when tools survive filtering, dropping it otherwise, to avoid a non-retryable 400 from upstream when tool_choice is sent with no tools. 8. Lower the fallback default to max_tokens=8192 (only when max_output_tokens is omitted) and clamp the thinking budget to max_tokens/2, disabling thinking below the 1024 floor, to avoid hard 400s on low-output-ceiling models/gateways. Minor: 9. Centralize the Codex/OpenAI fingerprint-header denylist in is_codex_client_fingerprint_header so impersonating Claude Code uniformly drops originator/session_id/conversation_id/chatgpt-account-id/x-client-request-id/openai-* and the x-stainless-*/x-codex-* prefixes. 10. Retain content_block_start.input as start_input and fall back to it at block close when no input_json_delta arrived, so a gateway that carries the full tool input on the start event no longer yields empty tool arguments. 11. Extract a shared codex_responses_sse module as the single Responses SSE envelope builder that both the chat and anthropic streaming emitters delegate to, with byte-for-byte-unchanged wire output, so future event-format fixes touch one place instead of two. * fix(codex): add per-provider max_output_tokens override for Codex→Anthropic path Codex does not forward model_max_output_tokens in the request body, causing the proxy to fall back to a conservative 8192 default. This truncates long or thinking-heavy responses (stop_reason=max_tokens). - Add maxOutputTokens field to ProviderMeta (Rust + TypeScript) - Inject the value into the Anthropic request body before transform, taking precedence over request-supplied and default values - Add numeric input in Codex form fields (Anthropic format only) - Add i18n entries for label, placeholder, and hint (en/zh/zh-TW/ja) - Include roundtrip and omission unit tests for the new field * fix(codex): harden Anthropic protocol bridge * fix(codex): address Anthropic bridge review * fix(codex): preserve flattened Anthropic inputs * fix(codex): harden Anthropic recovery paths * fix(ci): satisfy Rust clippy --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -84,6 +84,72 @@ pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &st
|
||||
) && codex_provider_uses_chat_completions(provider)
|
||||
}
|
||||
|
||||
/// Whether this Codex provider's real upstream speaks the native Anthropic
|
||||
/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC
|
||||
/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic.
|
||||
///
|
||||
/// Determined solely from explicit config (apiFormat / wire_api); no base_url
|
||||
/// guessing — Anthropic gateway addresses vary widely and guessing easily misfires.
|
||||
pub fn codex_provider_uses_anthropic(provider: &Provider) -> bool {
|
||||
if let Some(api_format) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.api_format.as_deref())
|
||||
.or_else(|| {
|
||||
provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.or_else(|| {
|
||||
provider
|
||||
.settings_config
|
||||
.get("apiFormat")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
{
|
||||
return is_anthropic_wire_api(api_format);
|
||||
}
|
||||
|
||||
provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_codex_wire_api_from_toml)
|
||||
.map(|wire_api| is_anthropic_wire_api(&wire_api))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint: &str) -> bool {
|
||||
let path = endpoint
|
||||
.split_once('?')
|
||||
.map_or(endpoint, |(path, _query)| path);
|
||||
|
||||
matches!(
|
||||
path,
|
||||
"/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact"
|
||||
) && codex_provider_uses_anthropic(provider)
|
||||
}
|
||||
|
||||
/// Resolve the model-catalog tool profile for a Codex provider using the SAME
|
||||
/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the
|
||||
/// generated catalog never disagrees with the routed transform. A provider whose
|
||||
/// Anthropic upstream is declared only via settings `apiFormat` or TOML `wire_api`
|
||||
/// (not `meta.api_format`) would otherwise get a `ProxyChat` catalog and emit the
|
||||
/// freeform `apply_patch` tool that the Anthropic transform then silently drops.
|
||||
/// Non-Anthropic providers keep the existing `meta.api_format` classification.
|
||||
pub fn resolve_codex_catalog_tool_profile(
|
||||
provider: &Provider,
|
||||
) -> crate::codex_config::CodexCatalogToolProfile {
|
||||
use crate::codex_config::CodexCatalogToolProfile;
|
||||
if codex_provider_uses_anthropic(provider) {
|
||||
return CodexCatalogToolProfile::Anthropic;
|
||||
}
|
||||
CodexCatalogToolProfile::from_api_format(
|
||||
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the real upstream model configured for a Codex provider.
|
||||
pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||||
provider
|
||||
@@ -129,7 +195,13 @@ pub fn apply_codex_chat_upstream_model(
|
||||
if !codex_provider_uses_chat_completions(provider) {
|
||||
return None;
|
||||
}
|
||||
apply_codex_upstream_model(provider, body)
|
||||
}
|
||||
|
||||
/// Same model-substitution logic as `apply_codex_chat_upstream_model`, but without
|
||||
/// the chat gating check. Reused by the anthropic conversion path (the forwarder has
|
||||
/// already confirmed this provider uses anthropic).
|
||||
pub fn apply_codex_upstream_model(provider: &Provider, body: &mut JsonValue) -> Option<String> {
|
||||
let catalog_model_ids = codex_provider_catalog_model_ids(provider);
|
||||
if let Some(request_model) = body
|
||||
.get("model")
|
||||
@@ -345,6 +417,13 @@ fn is_chat_wire_api(value: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_anthropic_wire_api(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"anthropic" | "anthropic_messages" | "anthropic-messages" | "claude" | "messages"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_chat_completions_url(value: &str) -> bool {
|
||||
value
|
||||
.trim_end_matches('/')
|
||||
@@ -527,8 +606,28 @@ impl ProviderAdapter for CodexAdapter {
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
|
||||
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
|
||||
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
|
||||
// The two are mutually exclusive to avoid a 401 from the gateway receiving
|
||||
// both auth headers at once. All other Codex upstreams stay pure Bearer.
|
||||
let strategy = if codex_provider_uses_anthropic(provider) {
|
||||
let uses_x_api_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.api_key_field.as_deref())
|
||||
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
|
||||
.unwrap_or(false);
|
||||
if uses_x_api_key {
|
||||
AuthStrategy::Anthropic
|
||||
} else {
|
||||
AuthStrategy::Bearer
|
||||
}
|
||||
} else {
|
||||
AuthStrategy::Bearer
|
||||
};
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
|
||||
.map(|key| AuthInfo::new(key, strategy))
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
@@ -569,6 +668,15 @@ impl ProviderAdapter for CodexAdapter {
|
||||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
|
||||
use super::adapter::auth_header_value;
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
// Anthropic gateway: send only x-api-key (anthropic-version is filled in by
|
||||
// the forwarder). Mutually exclusive with Bearer to avoid a 401 from the
|
||||
// gateway receiving both auth headers at once.
|
||||
if auth.strategy == AuthStrategy::Anthropic {
|
||||
return Ok(vec![(
|
||||
http::HeaderName::from_static("x-api-key"),
|
||||
auth_header_value(&auth.api_key)?,
|
||||
)]);
|
||||
}
|
||||
Ok(vec![(
|
||||
http::HeaderName::from_static("authorization"),
|
||||
auth_header_value(&bearer)?,
|
||||
@@ -662,6 +770,197 @@ experimental_bearer_token = "sk-config-key"
|
||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||
}
|
||||
|
||||
// ==================== anthropic upstream detection ====================
|
||||
|
||||
#[test]
|
||||
fn test_uses_anthropic_from_settings_api_format() {
|
||||
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
|
||||
let provider = create_provider(json!({ "api_format": "anthropic_messages" }));
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uses_anthropic_from_meta_api_format() {
|
||||
let mut provider = create_provider(json!({}));
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
api_format: Some("anthropic".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uses_anthropic_from_toml_wire_api() {
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
wire_api = "anthropic"
|
||||
"#
|
||||
}));
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_false_for_chat_and_responses() {
|
||||
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
|
||||
assert!(!codex_provider_uses_anthropic(&chat));
|
||||
let responses = create_provider(json!({ "apiFormat": "openai_responses" }));
|
||||
assert!(!codex_provider_uses_anthropic(&responses));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_and_chat_are_mutually_exclusive() {
|
||||
let anth = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert!(codex_provider_uses_anthropic(&anth));
|
||||
assert!(!codex_provider_uses_chat_completions(&anth));
|
||||
|
||||
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
|
||||
assert!(codex_provider_uses_chat_completions(&chat));
|
||||
assert!(!codex_provider_uses_anthropic(&chat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_convert_responses_to_anthropic_path_guard() {
|
||||
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert!(should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/responses"
|
||||
));
|
||||
assert!(should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/v1/responses/compact"
|
||||
));
|
||||
assert!(should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/responses?x=1"
|
||||
));
|
||||
assert!(!should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/chat/completions"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_catalog_profile_matches_router() {
|
||||
use crate::codex_config::CodexCatalogToolProfile;
|
||||
|
||||
// Anthropic declared only via TOML wire_api (no meta.api_format) must still
|
||||
// resolve to the Anthropic catalog profile — this is the routing/catalog
|
||||
// divergence that let apply_patch leak through.
|
||||
let toml_anthropic = create_provider(json!({
|
||||
"config": r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
wire_api = "anthropic"
|
||||
"#
|
||||
}));
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&toml_anthropic),
|
||||
CodexCatalogToolProfile::Anthropic
|
||||
);
|
||||
|
||||
// Anthropic via settings apiFormat.
|
||||
let settings_anthropic = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&settings_anthropic),
|
||||
CodexCatalogToolProfile::Anthropic
|
||||
);
|
||||
|
||||
// Native openai_responses (meta) → NativeResponses; chat → ProxyChat.
|
||||
let mut native = create_provider(json!({}));
|
||||
native.meta = Some(crate::provider::ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&native),
|
||||
CodexCatalogToolProfile::NativeResponses
|
||||
);
|
||||
|
||||
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&chat),
|
||||
CodexCatalogToolProfile::ProxyChat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_codex_upstream_model_preserves_one_m_catalog_model() {
|
||||
// Regression for the [1m] path: a request model carrying the [1m] marker must
|
||||
// match its catalog entry and be preserved (not overridden by the provider
|
||||
// default) so the transform can later strip [1m] and emit the context-1m beta.
|
||||
// This only works because the forwarder no longer strips [1m] before this call
|
||||
// on the Anthropic path.
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"model_provider = "custom"
|
||||
model = "claude-opus-4-1"
|
||||
|
||||
[model_providers.custom]
|
||||
wire_api = "anthropic"
|
||||
"#,
|
||||
"modelCatalog": {
|
||||
"models": [
|
||||
{ "model": "claude-opus-4-1[1m]" }
|
||||
]
|
||||
}
|
||||
}));
|
||||
let mut body = json!({ "model": "claude-opus-4-1[1m]", "input": "hi" });
|
||||
let result = apply_codex_upstream_model(&provider, &mut body);
|
||||
assert_eq!(result.as_deref(), Some("claude-opus-4-1[1m]"));
|
||||
assert_eq!(
|
||||
body.get("model").and_then(|v| v.as_str()),
|
||||
Some("claude-opus-4-1[1m]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_auth_defaults_to_bearer() {
|
||||
// No meta.apiKeyField (defaults to ANTHROPIC_AUTH_TOKEN) → Authorization: Bearer only
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"apiFormat": "anthropic",
|
||||
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||||
let names: Vec<String> = headers
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["authorization".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_auth_x_api_key_when_selected() {
|
||||
// meta.apiKeyField = ANTHROPIC_API_KEY → x-api-key only
|
||||
let adapter = CodexAdapter::new();
|
||||
let mut provider = create_provider(json!({
|
||||
"apiFormat": "anthropic",
|
||||
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
|
||||
}));
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
api_format: Some("anthropic".to_string()),
|
||||
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||||
let names: Vec<String> = headers
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["x-api-key".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_origin_adds_v1() {
|
||||
let adapter = CodexAdapter::new();
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Shared builders for the OpenAI Responses SSE envelope.
|
||||
//!
|
||||
//! The two Codex streaming converters — `streaming_codex_chat` (Chat Completions SSE →
|
||||
//! Responses SSE) and `streaming_codex_anthropic` (Anthropic Messages SSE → Responses
|
||||
//! SSE) — have completely different *input* state machines but must emit the identical
|
||||
//! Responses event stream the Codex client understands. This module owns that output
|
||||
//! envelope so the two converters cannot drift when an event's shape changes: a wire fix
|
||||
//! lands here once instead of being mirrored in both files.
|
||||
//!
|
||||
//! Each function is pure — it takes primitives or a caller-built `item` `Value` and
|
||||
//! returns the exact bytes the converters previously constructed inline. Item shapes that
|
||||
//! vary per converter (including function, namespace, custom, and tool-search calls)
|
||||
//! are supplied by the caller via the generic
|
||||
//! `output_item_added` / `output_item_done` helpers.
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Serialize one Responses SSE event with the standard `event:`/`data:` framing.
|
||||
pub(crate) fn sse_event(event: &str, data: Value) -> Bytes {
|
||||
Bytes::from(format!(
|
||||
"event: {event}\ndata: {}\n\n",
|
||||
serde_json::to_string(&data).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response lifecycle (created / in_progress / completed / failed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.created`, wrapping a caller-built `response` object (usage/created_at differ
|
||||
/// per converter, so the caller supplies the whole object).
|
||||
pub(crate) fn response_created(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.created",
|
||||
json!({ "type": "response.created", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.in_progress`.
|
||||
pub(crate) fn response_in_progress(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.in_progress",
|
||||
json!({ "type": "response.in_progress", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.completed`.
|
||||
pub(crate) fn response_completed(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.completed",
|
||||
json!({ "type": "response.completed", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.failed`.
|
||||
pub(crate) fn response_failed(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.failed",
|
||||
json!({ "type": "response.failed", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic output-item add/done (item value supplied by the caller)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.output_item.added` with a caller-built item (message / reasoning /
|
||||
/// function_call / custom_tool_call).
|
||||
pub(crate) fn output_item_added(output_index: u32, item: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.output_item.done` with a caller-built item.
|
||||
pub(crate) fn output_item_done(output_index: u32, item: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant message (text) lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.output_item.added` for an in-progress assistant message.
|
||||
pub(crate) fn message_item_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
output_item_added(
|
||||
output_index,
|
||||
&json!({
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.content_part.added` for the (empty) output_text part of a message.
|
||||
pub(crate) fn message_content_part_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.content_part.added",
|
||||
json!({
|
||||
"type": "response.content_part.added",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": { "type": "output_text", "text": "", "annotations": [] }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.output_text.delta`.
|
||||
pub(crate) fn output_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.output_text.delta",
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The completed assistant-message item value.
|
||||
pub(crate) fn message_item(item_id: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": text, "annotations": [] }]
|
||||
})
|
||||
}
|
||||
|
||||
/// Close an assistant message: emits `output_text.done` → `content_part.done` →
|
||||
/// `output_item.done`, and returns the completed item so the caller can record it.
|
||||
pub(crate) fn message_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
|
||||
let item = message_item(item_id, text);
|
||||
let events = vec![
|
||||
sse_event(
|
||||
"response.output_text.done",
|
||||
json!({
|
||||
"type": "response.output_text.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"text": text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.content_part.done",
|
||||
json!({
|
||||
"type": "response.content_part.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": { "type": "output_text", "text": text, "annotations": [] }
|
||||
}),
|
||||
),
|
||||
output_item_done(output_index, &item),
|
||||
];
|
||||
(events, item)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning (summary) lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.output_item.added` for an in-progress reasoning item.
|
||||
pub(crate) fn reasoning_item_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
output_item_added(
|
||||
output_index,
|
||||
&json!({
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"status": "in_progress",
|
||||
"summary": []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.reasoning_summary_part.added` for the (empty) summary part.
|
||||
pub(crate) fn reasoning_summary_part_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.reasoning_summary_part.added",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.added",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": { "type": "summary_text", "text": "" }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.reasoning_summary_text.delta`.
|
||||
pub(crate) fn reasoning_summary_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.reasoning_summary_text.delta",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The completed reasoning item value (note: no `status` field, matching both converters).
|
||||
pub(crate) fn reasoning_item(item_id: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"summary": [{ "type": "summary_text", "text": text }]
|
||||
})
|
||||
}
|
||||
|
||||
/// Close a reasoning item: emits `reasoning_summary_text.done` →
|
||||
/// `reasoning_summary_part.done` → `output_item.done`, and returns the completed item.
|
||||
pub(crate) fn reasoning_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
|
||||
let item = reasoning_item(item_id, text);
|
||||
let events = reasoning_close_with_item(output_index, item_id, text, &item, true);
|
||||
(events, item)
|
||||
}
|
||||
|
||||
/// Close a reasoning item whose completed shape is supplied by the converter.
|
||||
/// Anthropic uses this to attach opaque signed/redacted thinking in
|
||||
/// `encrypted_content` while keeping the standard Responses event lifecycle.
|
||||
pub(crate) fn reasoning_close_with_item(
|
||||
output_index: u32,
|
||||
item_id: &str,
|
||||
text: &str,
|
||||
item: &Value,
|
||||
has_visible_summary: bool,
|
||||
) -> Vec<Bytes> {
|
||||
let mut events = Vec::new();
|
||||
if has_visible_summary {
|
||||
events.extend([
|
||||
sse_event(
|
||||
"response.reasoning_summary_text.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"text": text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.reasoning_summary_part.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": { "type": "summary_text", "text": text }
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
events.push(output_item_done(output_index, item));
|
||||
events
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool-call argument streaming (item value supplied by the caller)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.function_call_arguments.delta`.
|
||||
pub(crate) fn function_call_arguments_delta(
|
||||
output_index: u32,
|
||||
item_id: &str,
|
||||
delta: &str,
|
||||
) -> Bytes {
|
||||
sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.function_call_arguments.done`.
|
||||
pub(crate) fn function_call_arguments_done(
|
||||
output_index: u32,
|
||||
item_id: &str,
|
||||
arguments: &str,
|
||||
) -> Bytes {
|
||||
sse_event(
|
||||
"response.function_call_arguments.done",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"arguments": arguments
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.custom_tool_call_input.delta` (Chat freeform tools only).
|
||||
pub(crate) fn custom_tool_call_input_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.custom_tool_call_input.delta",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.custom_tool_call_input.done` (Chat freeform tools only).
|
||||
pub(crate) fn custom_tool_call_input_done(output_index: u32, item_id: &str, input: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.custom_tool_call_input.done",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"input": input
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn body(bytes: &Bytes) -> String {
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_event_framing() {
|
||||
let ev = sse_event("response.created", json!({ "a": 1 }));
|
||||
assert_eq!(body(&ev), "event: response.created\ndata: {\"a\":1}\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_close_shapes_match_legacy() {
|
||||
let (events, item) = message_close(2, "resp_1_msg", "hi");
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(body(&events[0]).contains("\"type\":\"response.output_text.done\""));
|
||||
assert!(body(&events[0]).contains("\"text\":\"hi\""));
|
||||
assert!(body(&events[1]).contains("\"type\":\"response.content_part.done\""));
|
||||
assert!(body(&events[2]).contains("\"type\":\"response.output_item.done\""));
|
||||
assert_eq!(item["type"], "message");
|
||||
assert_eq!(item["status"], "completed");
|
||||
assert_eq!(item["content"][0]["text"], "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_close_item_has_no_status() {
|
||||
let (events, item) = reasoning_close(0, "rs_1", "because");
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(body(&events[0]).contains("\"type\":\"response.reasoning_summary_text.done\""));
|
||||
assert!(body(&events[1]).contains("\"type\":\"response.reasoning_summary_part.done\""));
|
||||
// The completed reasoning item intentionally carries no `status` field.
|
||||
assert!(item.get("status").is_none());
|
||||
assert_eq!(item["summary"][0]["text"], "because");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_item_added_is_in_progress() {
|
||||
let ev = message_item_added(0, "m1");
|
||||
let s = body(&ev);
|
||||
assert!(s.contains("\"type\":\"response.output_item.added\""));
|
||||
assert!(s.contains("\"status\":\"in_progress\""));
|
||||
assert!(s.contains("\"role\":\"assistant\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_argument_events() {
|
||||
assert!(body(&function_call_arguments_delta(1, "fc_x", "{\"a\":"))
|
||||
.contains("\"type\":\"response.function_call_arguments.delta\""));
|
||||
assert!(body(&function_call_arguments_done(1, "fc_x", "{\"a\":1}"))
|
||||
.contains("\"arguments\":\"{\\\"a\\\":1}\""));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod codex;
|
||||
pub(crate) mod codex_chat_common;
|
||||
pub mod codex_chat_history;
|
||||
pub mod codex_oauth_auth;
|
||||
pub(crate) mod codex_responses_sse;
|
||||
pub mod copilot_auth;
|
||||
pub mod copilot_model_map;
|
||||
mod gemini;
|
||||
@@ -25,10 +26,12 @@ pub(crate) mod gemini_schema;
|
||||
pub mod gemini_shadow;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_codex_anthropic;
|
||||
pub mod streaming_codex_chat;
|
||||
pub mod streaming_gemini;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_codex_anthropic;
|
||||
pub mod transform_codex_chat;
|
||||
pub mod transform_gemini;
|
||||
pub mod transform_responses;
|
||||
@@ -47,8 +50,9 @@ pub use claude::{
|
||||
};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use codex::{
|
||||
apply_codex_chat_upstream_model, codex_provider_upstream_model,
|
||||
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
|
||||
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
|
||||
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
|
||||
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
|
||||
};
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
//! OpenAI Chat Completions SSE → OpenAI Responses SSE conversion.
|
||||
|
||||
use super::codex_responses_sse as sse;
|
||||
use super::{
|
||||
codex_chat_common::{
|
||||
extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag,
|
||||
@@ -270,20 +271,8 @@ impl ChatToResponsesState {
|
||||
let response = self.base_response("in_progress", Vec::new());
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.created",
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": response
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.in_progress",
|
||||
json!({
|
||||
"type": "response.in_progress",
|
||||
"response": self.base_response("in_progress", Vec::new())
|
||||
}),
|
||||
),
|
||||
sse::response_created(&response),
|
||||
sse::response_in_progress(&response),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -297,45 +286,16 @@ impl ChatToResponsesState {
|
||||
self.reasoning.item_id = item_id.clone();
|
||||
self.reasoning.added = true;
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"status": "in_progress",
|
||||
"summary": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse_event(
|
||||
"response.reasoning_summary_part.added",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.added",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": {
|
||||
"type": "summary_text",
|
||||
"text": ""
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse::reasoning_item_added(output_index, &item_id));
|
||||
events.push(sse::reasoning_summary_part_added(output_index, &item_id));
|
||||
}
|
||||
|
||||
self.reasoning.text.push_str(delta);
|
||||
let output_index = self.reasoning.output_index.unwrap_or(0);
|
||||
events.push(sse_event(
|
||||
"response.reasoning_summary_text.delta",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
events.push(sse::reasoning_summary_text_delta(
|
||||
output_index,
|
||||
&self.reasoning.item_id,
|
||||
delta,
|
||||
));
|
||||
|
||||
events
|
||||
@@ -351,47 +311,16 @@ impl ChatToResponsesState {
|
||||
self.text.item_id = item_id.clone();
|
||||
self.text.added = true;
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse_event(
|
||||
"response.content_part.added",
|
||||
json!({
|
||||
"type": "response.content_part.added",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse::message_item_added(output_index, &item_id));
|
||||
events.push(sse::message_content_part_added(output_index, &item_id));
|
||||
}
|
||||
|
||||
self.text.text.push_str(delta);
|
||||
let output_index = self.text.output_index.unwrap_or(0);
|
||||
events.push(sse_event(
|
||||
"response.output_text.delta",
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
events.push(sse::output_text_delta(
|
||||
output_index,
|
||||
&self.text.item_id,
|
||||
delta,
|
||||
));
|
||||
|
||||
events
|
||||
@@ -485,36 +414,21 @@ impl ChatToResponsesState {
|
||||
&self.tool_context,
|
||||
);
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": assigned,
|
||||
"item": item
|
||||
}),
|
||||
));
|
||||
events.push(sse::output_item_added(assigned, &item));
|
||||
|
||||
if !pending_arguments.is_empty() && !is_custom_tool {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": state.item_id,
|
||||
"output_index": assigned,
|
||||
"delta": pending_arguments
|
||||
}),
|
||||
events.push(sse::function_call_arguments_delta(
|
||||
assigned,
|
||||
&state.item_id,
|
||||
&pending_arguments,
|
||||
));
|
||||
}
|
||||
} else if !args_delta.is_empty() && !is_custom_tool {
|
||||
if let Some(output_index) = output_index {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": args_delta
|
||||
}),
|
||||
events.push(sse::function_call_arguments_delta(
|
||||
output_index,
|
||||
&item_id,
|
||||
&args_delta,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -567,13 +481,7 @@ impl ChatToResponsesState {
|
||||
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
|
||||
}
|
||||
|
||||
events.push(sse_event(
|
||||
"response.completed",
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": response
|
||||
}),
|
||||
));
|
||||
events.push(sse::response_completed(&response));
|
||||
self.completed = true;
|
||||
events
|
||||
}
|
||||
@@ -586,50 +494,10 @@ impl ChatToResponsesState {
|
||||
let output_index = self.reasoning.output_index.unwrap_or(0);
|
||||
let item_id = self.reasoning.item_id.clone();
|
||||
let text = self.reasoning.text.clone();
|
||||
let item = json!({
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": text
|
||||
}]
|
||||
});
|
||||
self.output_items.push((output_index, item.clone()));
|
||||
let (events, item) = sse::reasoning_close(output_index, &item_id, &text);
|
||||
self.output_items.push((output_index, item));
|
||||
self.reasoning.done = true;
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.reasoning_summary_text.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.done",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"text": self.reasoning.text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.reasoning_summary_part.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.done",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": {
|
||||
"type": "summary_text",
|
||||
"text": self.reasoning.text
|
||||
}
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
),
|
||||
]
|
||||
events
|
||||
}
|
||||
|
||||
fn finalize_text(&mut self) -> Vec<Bytes> {
|
||||
@@ -638,54 +506,12 @@ impl ChatToResponsesState {
|
||||
}
|
||||
|
||||
let output_index = self.text.output_index.unwrap_or(0);
|
||||
let item = json!({
|
||||
"id": self.text.item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": self.text.text,
|
||||
"annotations": []
|
||||
}]
|
||||
});
|
||||
self.output_items.push((output_index, item.clone()));
|
||||
let item_id = self.text.item_id.clone();
|
||||
let text = self.text.text.clone();
|
||||
let (events, item) = sse::message_close(output_index, &item_id, &text);
|
||||
self.output_items.push((output_index, item));
|
||||
self.text.done = true;
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.output_text.done",
|
||||
json!({
|
||||
"type": "response.output_text.done",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"text": self.text.text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.content_part.done",
|
||||
json!({
|
||||
"type": "response.content_part.done",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": self.text.text,
|
||||
"annotations": []
|
||||
}
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
),
|
||||
]
|
||||
events
|
||||
}
|
||||
|
||||
fn finalize_tools(&mut self) -> Vec<Bytes> {
|
||||
@@ -742,14 +568,7 @@ impl ChatToResponsesState {
|
||||
Some(&state.reasoning_content),
|
||||
&self.tool_context,
|
||||
);
|
||||
add_event = Some(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": assigned,
|
||||
"item": item
|
||||
}),
|
||||
));
|
||||
add_event = Some(sse::output_item_added(assigned, &item));
|
||||
}
|
||||
|
||||
if let Some(event) = add_event {
|
||||
@@ -777,44 +596,25 @@ impl ChatToResponsesState {
|
||||
if is_custom_tool {
|
||||
let input = custom_tool_input_from_chat_arguments(&arguments);
|
||||
if !input.is_empty() {
|
||||
events.push(sse_event(
|
||||
"response.custom_tool_call_input.delta",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.delta",
|
||||
"item_id": state.item_id,
|
||||
"output_index": output_index,
|
||||
"delta": input.clone()
|
||||
}),
|
||||
events.push(sse::custom_tool_call_input_delta(
|
||||
output_index,
|
||||
&state.item_id,
|
||||
&input,
|
||||
));
|
||||
}
|
||||
events.push(sse_event(
|
||||
"response.custom_tool_call_input.done",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.done",
|
||||
"item_id": state.item_id,
|
||||
"output_index": output_index,
|
||||
"input": input
|
||||
}),
|
||||
events.push(sse::custom_tool_call_input_done(
|
||||
output_index,
|
||||
&state.item_id,
|
||||
&input,
|
||||
));
|
||||
} else {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.done",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": state.item_id,
|
||||
"output_index": output_index,
|
||||
"arguments": arguments
|
||||
}),
|
||||
events.push(sse::function_call_arguments_done(
|
||||
output_index,
|
||||
&state.item_id,
|
||||
&arguments,
|
||||
));
|
||||
}
|
||||
events.push(sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
));
|
||||
events.push(sse::output_item_done(output_index, &item));
|
||||
}
|
||||
|
||||
events
|
||||
@@ -864,13 +664,7 @@ impl ChatToResponsesState {
|
||||
let mut response = self.base_response("failed", self.completed_output_items());
|
||||
response["error"] = error;
|
||||
|
||||
sse_event(
|
||||
"response.failed",
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": response
|
||||
}),
|
||||
)
|
||||
sse::response_failed(&response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,13 +824,6 @@ fn extract_chat_sse_error(value: &Value) -> (String, Option<String>) {
|
||||
(message, error_type)
|
||||
}
|
||||
|
||||
fn sse_event(event: &str, data: Value) -> Bytes {
|
||||
Bytes::from(format!(
|
||||
"event: {event}\ndata: {}\n\n",
|
||||
serde_json::to_string(&data).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,11 @@ impl CodexToolContext {
|
||||
.is_some_and(|spec| matches!(&spec.kind, CodexToolKind::Custom))
|
||||
}
|
||||
|
||||
fn chat_name_for_response_function(&self, name: &str, namespace: Option<&str>) -> String {
|
||||
pub(crate) fn chat_name_for_response_function(
|
||||
&self,
|
||||
name: &str,
|
||||
namespace: Option<&str>,
|
||||
) -> String {
|
||||
if let Some(namespace) = namespace.filter(|value| !value.is_empty()) {
|
||||
if let Some(chat_name) = self
|
||||
.namespace_name_to_chat_name
|
||||
|
||||
Reference in New Issue
Block a user