diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 21d8784c3..5ec61285c 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -5378,6 +5413,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6b12cee9c..8cec89499 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1094,6 +1094,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason2" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -4210,6 +4239,45 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelRerouteReason2": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "type": "object" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -8276,6 +8344,26 @@ "title": "Thread/compactedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5f352004c..09375c5ed 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2486,6 +2486,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/v2/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -6266,6 +6295,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ @@ -8491,6 +8526,26 @@ "title": "Thread/compactedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12750,6 +12805,41 @@ "title": "ModelListResponse", "type": "object" }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/v2/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 225ea3f42..a73c550f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index 2b305281c..6dc02934d 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index d4bbf908d..69967e685 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -473,6 +473,35 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, { "description": "Conversation history was compacted (either automatically or manually).", "properties": { @@ -3318,6 +3347,12 @@ ], "type": "string" }, + "ModelRerouteReason": { + "enum": [ + "high_risk_cyber_activity" + ], + "type": "string" + }, "NetworkAccess": { "description": "Represents whether outbound network access is available to the agent.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json new file mode 100644 index 000000000..b9bcc491b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + } + }, + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index 2c0514870..5ed3ca252 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -42,6 +42,7 @@ import type { McpStartupCompleteEvent } from "./McpStartupCompleteEvent"; import type { McpStartupUpdateEvent } from "./McpStartupUpdateEvent"; import type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; import type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; +import type { ModelRerouteEvent } from "./ModelRerouteEvent"; import type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent"; import type { PatchApplyEndEvent } from "./PatchApplyEndEvent"; import type { PlanDeltaEvent } from "./PlanDeltaEvent"; @@ -74,4 +75,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent"; * Response event from the agent * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; +export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; diff --git a/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts new file mode 100644 index 000000000..23a4e1efb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteEvent.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelRerouteReason } from "./ModelRerouteReason"; + +export type ModelRerouteEvent = { from_model: string, to_model: string, reason: ModelRerouteReason, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts new file mode 100644 index 000000000..f5e1abf1e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ModelRerouteReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelRerouteReason = "high_risk_cyber_activity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index ffc39598b..171da7425 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -21,6 +21,7 @@ import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; import type { ItemStartedNotification } from "./v2/ItemStartedNotification"; import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; +import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; @@ -39,4 +40,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 3c0826712..2a626d363 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -125,6 +125,8 @@ export type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; export type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; +export type { ModelRerouteEvent } from "./ModelRerouteEvent"; +export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts new file mode 100644 index 000000000..e780e7f95 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelRerouteReason = "highRiskCyberActivity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts new file mode 100644 index 000000000..9b6b2e524 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelRerouteReason } from "./ModelRerouteReason"; + +export type ModelReroutedNotification = { threadId: string, turnId: string, fromModel: string, toModel: string, reason: ModelRerouteReason, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 500b1d930..423ee9dd1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -91,6 +91,8 @@ export type { MergeStrategy } from "./MergeStrategy"; export type { Model } from "./Model"; export type { ModelListParams } from "./ModelListParams"; export type { ModelListResponse } from "./ModelListResponse"; +export type { ModelRerouteReason } from "./ModelRerouteReason"; +export type { ModelReroutedNotification } from "./ModelReroutedNotification"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkRequirements } from "./NetworkRequirements"; export type { OverriddenMetadata } from "./OverriddenMetadata"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 6c58d3b90..e6b285d9d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -795,6 +795,7 @@ server_notification_definitions! { ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification), /// Deprecated: Use `ContextCompaction` item type instead. ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), + ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9a184d175..fc4a7742f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -30,6 +30,7 @@ use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; @@ -229,6 +230,12 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum ModelRerouteReason from CoreModelRerouteReason { + HighRiskCyberActivity + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -3305,6 +3312,17 @@ pub struct AccountLoginCompletedNotification { pub error: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelReroutedNotification { + pub thread_id: String, + pub turn_id: String, + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 27bb78e49..669e8a157 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -550,6 +550,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. +- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. @@ -557,7 +558,7 @@ Today both notifications carry an empty `items` array even when item events were `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: -- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). Cyber model-routing warnings are surfaced as synthetic `userMessage` items with `text` prefixed by `Warning:`. +- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 87ddf7e18..3acdefc73 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::PlanDeltaNotification; @@ -68,7 +69,6 @@ use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStatus; -use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_core::CodexThread; use codex_core::parse_command::shlex_join; @@ -96,8 +96,6 @@ use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUse use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; -use std::hash::Hash; -use std::hash::Hasher; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -125,32 +123,18 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::TurnComplete(_ev) => { handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; } - EventMsg::Warning(warning_event) => { - if matches!(api_version, ApiVersion::V2) - && is_safety_check_downgrade_warning(&warning_event.message) - { - let item = ThreadItem::UserMessage { - id: warning_item_id(&event_turn_id, &warning_event.message), - content: vec![V2UserInput::Text { - text: format!("Warning: {}", warning_event.message), - text_elements: Vec::new(), - }], - }; - let started = ItemStartedNotification { + EventMsg::Warning(_warning_event) => {} + EventMsg::ModelReroute(event) => { + if let ApiVersion::V2 = api_version { + let notification = ModelReroutedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), - item: item.clone(), + from_model: event.from_model, + to_model: event.to_model, + reason: event.reason.into(), }; outgoing - .send_server_notification(ServerNotification::ItemStarted(started)) - .await; - let completed = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(completed)) + .send_server_notification(ServerNotification::ModelRerouted(notification)) .await; } } @@ -1318,18 +1302,6 @@ async fn complete_command_execution_item( .await; } -fn is_safety_check_downgrade_warning(message: &str) -> bool { - message.contains("Your account was flagged for potentially high-risk cyber activity") - && message.contains("apply for trusted access: https://chatgpt.com/cyber") -} - -fn warning_item_id(turn_id: &str, message: &str) -> String { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - message.hash(&mut hasher); - let digest = hasher.finish(); - format!("{turn_id}-warning-{digest:x}") -} - async fn maybe_emit_raw_response_item_completed( api_version: ApiVersion, conversation_id: ThreadId, @@ -2060,18 +2032,6 @@ mod tests { assert_eq!(item, expected); } - #[test] - fn safety_check_downgrade_warning_detection_matches_expected_message() { - let warning = "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: https://chatgpt.com/cyber\nLearn more: https://developers.openai.com/codex/concepts/cyber-safety"; - assert!(is_safety_check_downgrade_warning(warning)); - } - - #[test] - fn safety_check_downgrade_warning_detection_ignores_other_warnings() { - let warning = "Model metadata for `mock-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."; - assert!(!is_safety_check_downgrade_warning(warning)); - } - #[tokio::test] async fn test_handle_error_records_message() -> Result<()> { let conversation_id = ThreadId::new(); diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 2d8b748c9..810182db0 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -399,6 +399,8 @@ mod tests { use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::LoginChatGptCompleteNotification; + use codex_app_server_protocol::ModelRerouteReason; + use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_protocol::ThreadId; @@ -546,6 +548,34 @@ mod tests { ); } + #[test] + fn verify_model_rerouted_notification_serialization() { + let notification = ServerNotification::ModelRerouted(ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5.3-codex".to_string(), + to_model: "gpt-5.2".to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "model/rerouted", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "fromModel": "gpt-5.3-codex", + "toModel": "gpt-5.2", + "reason": "highRiskCyberActivity", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 20a7c6023..53acd492b 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -3,8 +3,10 @@ use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; -use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ModelRerouteReason; +use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; @@ -23,7 +25,7 @@ const REQUESTED_MODEL: &str = "gpt-5.1-codex-max"; const SERVER_MODEL: &str = "gpt-5.2-codex"; #[tokio::test] -async fn openai_model_header_mismatch_emits_warning_item_v2() -> Result<()> { +async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -64,64 +66,30 @@ async fn openai_model_header_mismatch_emits_warning_item_v2() -> Result<()> { ..Default::default() }) .await?; - let _turn_resp: JSONRPCResponse = timeout( + let turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), ) .await??; - let _turn_start: TurnStartResponse = to_response(_turn_resp)?; + let turn_start: TurnStartResponse = to_response(turn_resp)?; - let warning_started = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/started") - .await?; - let params = notification.params.expect("item/started params"); - let started: ItemStartedNotification = - serde_json::from_value(params).expect("deserialize item/started"); - if warning_text_from_item(&started.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(started); - } - } - }) - .await??; - - let warning_text = - warning_text_from_item(&warning_started.item).expect("expected warning user message item"); - assert!(warning_text.contains("Warning:")); - assert!(warning_text.contains("gpt-5.2 as a fallback")); - assert!(warning_text.contains("regain access to gpt-5.3-codex")); - - let warning_completed = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/completed") - .await?; - let params = notification.params.expect("item/completed params"); - let completed: ItemCompletedNotification = - serde_json::from_value(params).expect("deserialize item/completed"); - if warning_text_from_item(&completed.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(completed); - } - } - }) - .await??; + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; assert_eq!( - warning_text_from_item(&warning_completed.item), - warning_text_from_item(&warning_started.item) + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } ); - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - Ok(()) } #[tokio::test] -async fn response_model_field_mismatch_emits_warning_item_v2_when_header_matches_requested() +async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested() -> Result<()> { skip_if_no_network!(Ok(())); @@ -174,54 +142,65 @@ async fn response_model_field_mismatch_emits_warning_item_v2_when_header_matches mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), ) .await??; - let _turn_start: TurnStartResponse = to_response(turn_resp)?; + let turn_start: TurnStartResponse = to_response(turn_resp)?; - let warning_started = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/started") - .await?; - let params = notification.params.expect("item/started params"); - let started: ItemStartedNotification = - serde_json::from_value(params).expect("deserialize item/started"); - if warning_text_from_item(&started.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(started); - } - } - }) - .await??; - let warning_text = - warning_text_from_item(&warning_started.item).expect("expected warning user message item"); - assert!(warning_text.contains("gpt-5.2 as a fallback")); - - let warning_completed = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let notification: JSONRPCNotification = mcp - .read_stream_until_notification_message("item/completed") - .await?; - let params = notification.params.expect("item/completed params"); - let completed: ItemCompletedNotification = - serde_json::from_value(params).expect("deserialize item/completed"); - if warning_text_from_item(&completed.item).is_some_and(is_cyber_model_warning_text) { - return Ok::(completed); - } - } - }) - .await??; + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; assert_eq!( - warning_text_from_item(&warning_completed.item), - warning_text_from_item(&warning_started.item) + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } ); - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - Ok(()) } +async fn collect_turn_notifications_and_validate_no_warning_item( + mcp: &mut McpProcess, +) -> Result { + let mut rerouted = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "model/rerouted" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("model/rerouted notifications must include params") + })?; + let payload: ModelReroutedNotification = serde_json::from_value(params)?; + rerouted = Some(payload); + } + "item/started" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/started notifications must include params") + })?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "item/completed" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/completed notifications must include params") + })?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "turn/completed" => { + return rerouted.ok_or_else(|| { + anyhow::anyhow!("expected model/rerouted notification before turn/completed") + }); + } + _ => {} + } + } +} + fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { let ThreadItem::UserMessage { content, .. } = item else { return None; @@ -233,9 +212,8 @@ fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { }) } -fn is_cyber_model_warning_text(text: &str) -> bool { - text.contains("flagged for potentially high-risk cyber activity") - && text.contains("apply for trusted access: https://chatgpt.com/cyber") +fn is_warning_user_message_item(item: &ThreadItem) -> bool { + warning_text_from_item(item).is_some() } fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5eb80de2e..f73cef0eb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -172,6 +172,8 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; use crate::protocol::McpServerRefreshConfig; +use crate::protocol::ModelRerouteEvent; +use crate::protocol::ModelRerouteReason; use crate::protocol::NetworkApprovalContext; use crate::protocol::Op; use crate::protocol::PlanDeltaEvent; @@ -2440,7 +2442,7 @@ impl Session { turn_context: &Arc, server_model: String, ) -> bool { - let requested_model = turn_context.model_info.slug.as_str(); + let requested_model = turn_context.model_info.slug.clone(); if server_model == requested_model { info!("server reported model {server_model} (matches requested model)"); return false; @@ -2452,6 +2454,16 @@ impl Session { "Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: {CYBER_VERIFY_URL} or learn more: {CYBER_SAFETY_URL}" ); + self.send_event( + turn_context, + EventMsg::ModelReroute(ModelRerouteEvent { + from_model: requested_model.clone(), + to_model: server_model.clone(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }), + ) + .await; + self.send_event( turn_context, EventMsg::Warning(WarningEvent { diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index dc3e111a6..f83433efb 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -122,6 +122,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeEnd(_) => Some(EventPersistenceMode::Extended), EventMsg::Warning(_) + | EventMsg::ModelReroute(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index a22fead90..62a46bf1d 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; +use codex_core::protocol::ModelRerouteReason; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; @@ -54,6 +55,17 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> }) .await?; + let reroute = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ModelReroute(_)) + }) + .await; + let EventMsg::ModelReroute(reroute) = reroute else { + panic!("expected model reroute event"); + }; + assert_eq!(reroute.from_model, REQUESTED_MODEL); + assert_eq!(reroute.to_model, SERVER_MODEL); + assert_eq!(reroute.reason, ModelRerouteReason::HighRiskCyberActivity); + let warning = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Warning(_))).await; let EventMsg::Warning(warning) = warning else { panic!("expected warning event"); @@ -138,6 +150,17 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request }) .await?; + let reroute = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ModelReroute(_)) + }) + .await; + let EventMsg::ModelReroute(reroute) = reroute else { + panic!("expected model reroute event"); + }; + assert_eq!(reroute.from_model, REQUESTED_MODEL); + assert_eq!(reroute.to_model, SERVER_MODEL); + assert_eq!(reroute.reason, ModelRerouteReason::HighRiskCyberActivity); + let warning = wait_for_event(&test.codex, |event| { matches!( event, @@ -151,7 +174,8 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request let EventMsg::Warning(warning) = warning else { panic!("expected warning event"); }; - assert!(warning.message.contains("gpt-5.2 as a fallback")); + assert!(warning.message.contains(REQUESTED_MODEL)); + assert!(warning.message.contains(SERVER_MODEL)); let _ = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index d7b95e288..26861af7c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -188,6 +188,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { "warning:".style(self.yellow).style(self.bold) ); } + EventMsg::ModelReroute(_) => {} EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { ts_msg!( self, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 5af2191fc..04fa65801 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -254,6 +254,9 @@ async fn run_codex_tool_session_inner( EventMsg::Warning(_) => { continue; } + EventMsg::ModelReroute(_) => { + continue; + } EventMsg::ElicitationRequest(_) => { // TODO: forward elicitation requests to the client? continue; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8a7fa9c38..bf489059b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -883,6 +883,9 @@ pub enum EventMsg { /// indicates the turn continued but the user should still be notified. Warning(WarningEvent), + /// Model routing changed from the requested model to a different model. + ModelReroute(ModelRerouteEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1342,6 +1345,20 @@ pub struct WarningEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ModelRerouteReason { + HighRiskCyberActivity, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct ModelRerouteEvent { + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dcbee999b..9da2e6f9c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4021,6 +4021,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { message, codex_error_info,