mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add safety check notification and error handling (#19055)
Adds a new app-server notification that fires when a user account has been flagged for potential safety reasons.
This commit is contained in:
committed by
GitHub
Unverified
parent
02170996e6
commit
bbff4ee61a
@@ -480,6 +480,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
@@ -2260,6 +2261,34 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModelVerification": {
|
||||
"enum": [
|
||||
"trustedAccessForCyber"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ModelVerificationNotification": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
},
|
||||
"verifications": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ModelVerification"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId",
|
||||
"turnId",
|
||||
"verifications"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalProtocol": {
|
||||
"enum": [
|
||||
"http",
|
||||
@@ -5339,6 +5368,26 @@
|
||||
"title": "Model/reroutedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"model/verification"
|
||||
],
|
||||
"title": "Model/verificationNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ModelVerificationNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Model/verificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
+51
@@ -4424,6 +4424,26 @@
|
||||
"title": "Model/reroutedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"model/verification"
|
||||
],
|
||||
"title": "Model/verificationNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ModelVerificationNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Model/verificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -5985,6 +6005,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
@@ -10893,6 +10914,36 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModelVerification": {
|
||||
"enum": [
|
||||
"trustedAccessForCyber"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ModelVerificationNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
},
|
||||
"verifications": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ModelVerification"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId",
|
||||
"turnId",
|
||||
"verifications"
|
||||
],
|
||||
"title": "ModelVerificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
"restricted",
|
||||
|
||||
+51
@@ -2565,6 +2565,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
@@ -7628,6 +7629,36 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModelVerification": {
|
||||
"enum": [
|
||||
"trustedAccessForCyber"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ModelVerificationNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
},
|
||||
"verifications": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ModelVerification"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId",
|
||||
"turnId",
|
||||
"verifications"
|
||||
],
|
||||
"title": "ModelVerificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkAccess": {
|
||||
"enum": [
|
||||
"restricted",
|
||||
@@ -10941,6 +10972,26 @@
|
||||
"title": "Model/reroutedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"model/verification"
|
||||
],
|
||||
"title": "Model/verificationNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ModelVerificationNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Model/verificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ModelVerification": {
|
||||
"enum": [
|
||||
"trustedAccessForCyber"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
},
|
||||
"verifications": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ModelVerification"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId",
|
||||
"turnId",
|
||||
"verifications"
|
||||
],
|
||||
"title": "ModelVerificationNotification",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
+1
@@ -35,6 +35,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
"serverOverloaded",
|
||||
"cyberPolicy",
|
||||
"internalServerError",
|
||||
"unauthorized",
|
||||
"badRequest",
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOau
|
||||
import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification";
|
||||
import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification";
|
||||
import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification";
|
||||
import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification";
|
||||
import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification";
|
||||
import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification";
|
||||
import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification";
|
||||
@@ -63,4 +64,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/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "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": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
|
||||
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "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": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
|
||||
|
||||
@@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
|
||||
* When an upstream HTTP status is available (for example, from the Responses API or a provider),
|
||||
* it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
|
||||
*/
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other";
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other";
|
||||
|
||||
@@ -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 ModelVerification = "trustedAccessForCyber";
|
||||
+6
@@ -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 { ModelVerification } from "./ModelVerification";
|
||||
|
||||
export type ModelVerificationNotification = { threadId: string, turnId: string, verifications: Array<ModelVerification>, };
|
||||
@@ -231,6 +231,8 @@ export type { ModelListResponse } from "./ModelListResponse";
|
||||
export type { ModelRerouteReason } from "./ModelRerouteReason";
|
||||
export type { ModelReroutedNotification } from "./ModelReroutedNotification";
|
||||
export type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
|
||||
export type { ModelVerification } from "./ModelVerification";
|
||||
export type { ModelVerificationNotification } from "./ModelVerificationNotification";
|
||||
export type { NetworkAccess } from "./NetworkAccess";
|
||||
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
|
||||
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
|
||||
|
||||
@@ -1060,6 +1060,7 @@ server_notification_definitions! {
|
||||
/// Deprecated: Use `ContextCompaction` item type instead.
|
||||
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
|
||||
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
|
||||
ModelVerification => "model/verification" (v2::ModelVerificationNotification),
|
||||
Warning => "warning" (v2::WarningNotification),
|
||||
GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification),
|
||||
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
|
||||
|
||||
@@ -72,6 +72,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
|
||||
use codex_protocol::protocol::HookScope as CoreHookScope;
|
||||
use codex_protocol::protocol::HookSource as CoreHookSource;
|
||||
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
|
||||
use codex_protocol::protocol::ModelVerification as CoreModelVerification;
|
||||
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind;
|
||||
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
|
||||
@@ -163,6 +164,7 @@ pub enum CodexErrorInfo {
|
||||
ContextWindowExceeded,
|
||||
UsageLimitExceeded,
|
||||
ServerOverloaded,
|
||||
CyberPolicy,
|
||||
HttpConnectionFailed {
|
||||
#[serde(rename = "httpStatusCode")]
|
||||
#[ts(rename = "httpStatusCode")]
|
||||
@@ -207,6 +209,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
|
||||
CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
|
||||
CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded,
|
||||
CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
|
||||
CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy,
|
||||
CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => {
|
||||
CodexErrorInfo::HttpConnectionFailed { http_status_code }
|
||||
}
|
||||
@@ -425,6 +428,12 @@ v2_enum_from_core!(
|
||||
}
|
||||
);
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum ModelVerification from CoreModelVerification {
|
||||
TrustedAccessForCyber
|
||||
}
|
||||
);
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum HookEventName from CoreHookEventName {
|
||||
PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop
|
||||
@@ -7350,6 +7359,15 @@ pub struct ModelReroutedNotification {
|
||||
pub reason: ModelRerouteReason,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ModelVerificationNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub verifications: Vec<ModelVerification>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -9952,6 +9970,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_error_info_serializes_cyber_policy_in_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(),
|
||||
json!("cyberPolicy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() {
|
||||
let value = CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
|
||||
@@ -1028,6 +1028,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
|
||||
- `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).
|
||||
- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ 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::ModelVerificationNotification;
|
||||
use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext;
|
||||
use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment;
|
||||
use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction;
|
||||
@@ -399,6 +400,18 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::ModelVerification(event) => {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = ModelVerificationNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
verifications: event.verifications.into_iter().map(Into::into).collect(),
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ModelVerification(notification))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::RealtimeConversationStarted(event) => {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = ThreadRealtimeStartedNotification {
|
||||
|
||||
@@ -660,6 +660,8 @@ mod tests {
|
||||
use codex_app_server_protocol::GuardianWarningNotification;
|
||||
use codex_app_server_protocol::ModelRerouteReason;
|
||||
use codex_app_server_protocol::ModelReroutedNotification;
|
||||
use codex_app_server_protocol::ModelVerification;
|
||||
use codex_app_server_protocol::ModelVerificationNotification;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
@@ -863,6 +865,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_model_verification_notification_serialization() {
|
||||
let notification = ServerNotification::ModelVerification(ModelVerificationNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
verifications: vec![ModelVerification::TrustedAccessForCyber],
|
||||
});
|
||||
|
||||
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "model/verification",
|
||||
"params": {
|
||||
"threadId": "thread-1",
|
||||
"turnId": "turn-1",
|
||||
"verifications": ["trustedAccessForCyber"],
|
||||
},
|
||||
}),
|
||||
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::<OutgoingEnvelope>(4);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CodexErrorInfo;
|
||||
use codex_app_server_protocol::ErrorNotification;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
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::ModelVerification;
|
||||
use codex_app_server_protocol::ModelVerificationNotification;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
@@ -19,10 +23,14 @@ use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const REQUESTED_MODEL: &str = "gpt-5.4";
|
||||
const SERVER_MODEL: &str = "gpt-5.3-codex";
|
||||
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
|
||||
const CYBER_POLICY_MESSAGE: &str =
|
||||
"This request has been flagged for potentially high-risk cyber activity.";
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> {
|
||||
@@ -88,6 +96,75 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": CYBER_POLICY_MESSAGE,
|
||||
"type": "invalid_request",
|
||||
"param": null,
|
||||
"code": "cyber_policy"
|
||||
}
|
||||
}));
|
||||
let _response_mock = responses::mount_response_once(&server, response).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some(REQUESTED_MODEL.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "trigger cyber policy error".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
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 error = collect_cyber_policy_error_and_validate_no_reroute(&mut mcp).await?;
|
||||
assert_eq!(
|
||||
error,
|
||||
ErrorNotification {
|
||||
error: codex_app_server_protocol::TurnError {
|
||||
message: CYBER_POLICY_MESSAGE.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::CyberPolicy),
|
||||
additional_details: None,
|
||||
},
|
||||
will_retry: false,
|
||||
thread_id: thread.id,
|
||||
turn_id: turn_start.turn.id,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested()
|
||||
-> Result<()> {
|
||||
@@ -161,6 +238,73 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_verification_emits_typed_notification_and_warning_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_model_verification_metadata(
|
||||
"resp-1",
|
||||
vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION],
|
||||
),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let response = responses::sse_response(body);
|
||||
let _response_mock = responses::mount_response_once(&server, response).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some(REQUESTED_MODEL.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "trigger model verification".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
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 verification =
|
||||
collect_model_verification_notifications_and_validate_no_warning_item(&mut mcp).await?;
|
||||
assert_eq!(
|
||||
verification,
|
||||
ModelVerificationNotification {
|
||||
thread_id: thread.id,
|
||||
turn_id: turn_start.turn.id,
|
||||
verifications: vec![ModelVerification::TrustedAccessForCyber],
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_turn_notifications_and_validate_no_warning_item(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<ModelReroutedNotification> {
|
||||
@@ -203,6 +347,90 @@ async fn collect_turn_notifications_and_validate_no_warning_item(
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_model_verification_notifications_and_validate_no_warning_item(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<ModelVerificationNotification> {
|
||||
let mut verification = 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/verification" => {
|
||||
let params = notification.params.ok_or_else(|| {
|
||||
anyhow::anyhow!("model/verification notifications must include params")
|
||||
})?;
|
||||
let payload: ModelVerificationNotification = serde_json::from_value(params)?;
|
||||
verification = Some(payload);
|
||||
}
|
||||
"warning" => {
|
||||
anyhow::bail!("verification-only response must not emit warning");
|
||||
}
|
||||
"model/rerouted" => {
|
||||
anyhow::bail!("verification-only response must not emit model/rerouted");
|
||||
}
|
||||
"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" => {
|
||||
let verification = verification.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"expected model/verification notification before turn/completed"
|
||||
)
|
||||
})?;
|
||||
return Ok(verification);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_cyber_policy_error_and_validate_no_reroute(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<ErrorNotification> {
|
||||
let mut error = 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() {
|
||||
"error" => {
|
||||
let params = notification
|
||||
.params
|
||||
.ok_or_else(|| anyhow::anyhow!("error notifications must include params"))?;
|
||||
let payload: ErrorNotification = serde_json::from_value(params)?;
|
||||
if payload.error.codex_error_info == Some(CodexErrorInfo::CyberPolicy) {
|
||||
error = Some(payload);
|
||||
}
|
||||
}
|
||||
"model/rerouted" => {
|
||||
anyhow::bail!("cyber policy response must not emit model/rerouted");
|
||||
}
|
||||
"turn/completed" => {
|
||||
return error.ok_or_else(|| {
|
||||
anyhow::anyhow!("expected cyber policy error before turn/completed")
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_text_from_item(item: &ThreadItem) -> Option<&str> {
|
||||
let ThreadItem::UserMessage { content, .. } = item else {
|
||||
return None;
|
||||
|
||||
@@ -69,6 +69,9 @@ fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value
|
||||
codex_api::ResponseEvent::ServerModel(model) => {
|
||||
json!({ "type": "response.server_model", "model": model })
|
||||
}
|
||||
codex_api::ResponseEvent::ModelVerifications(verifications) => {
|
||||
json!({ "type": "response.model_verifications", "verifications": verifications })
|
||||
}
|
||||
codex_api::ResponseEvent::ServerReasoningIncluded(included) => {
|
||||
json!({ "type": "response.server_reasoning_included", "included": included })
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
identity_error_code: None,
|
||||
}),
|
||||
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
|
||||
ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message },
|
||||
ApiError::Transport(transport) => match transport {
|
||||
TransportError::Http {
|
||||
status,
|
||||
@@ -55,7 +56,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
}
|
||||
|
||||
if status == http::StatusCode::BAD_REQUEST {
|
||||
if body_text
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&body_text)
|
||||
&& let Some(error) = parsed.get("error")
|
||||
&& error.get("code").and_then(Value::as_str)
|
||||
== Some(CYBER_POLICY_ERROR_CODE)
|
||||
{
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string());
|
||||
CodexErr::CyberPolicy { message }
|
||||
} else if body_text
|
||||
.contains("The image data you provided does not represent a valid image")
|
||||
{
|
||||
CodexErr::InvalidImageRequest()
|
||||
@@ -125,6 +138,9 @@ const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
|
||||
const CF_RAY_HEADER: &str = "cf-ray";
|
||||
const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error";
|
||||
const X_ERROR_JSON_HEADER: &str = "x-error-json";
|
||||
const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy";
|
||||
const CYBER_POLICY_FALLBACK_MESSAGE: &str =
|
||||
"This request has been flagged for possible cybersecurity risk.";
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "api_bridge_tests.rs"]
|
||||
|
||||
@@ -26,6 +26,104 @@ fn map_api_error_maps_server_overloaded_from_503_body() {
|
||||
assert!(matches!(err, CodexErr::ServerOverloaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_cyber_policy_from_400_body() {
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"message": "This request has been flagged for potentially high-risk cyber activity.",
|
||||
"type": "invalid_request",
|
||||
"param": null,
|
||||
"code": "cyber_policy"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::BAD_REQUEST,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: None,
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
let CodexErr::CyberPolicy { message } = err else {
|
||||
panic!("expected CodexErr::CyberPolicy, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
message,
|
||||
"This request has been flagged for potentially high-risk cyber activity."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_wrapped_websocket_cyber_policy_from_400_body() {
|
||||
let body = serde_json::json!({
|
||||
"type": "error",
|
||||
"status": 400,
|
||||
"error": {
|
||||
"message": "This websocket request was flagged.",
|
||||
"type": "invalid_request",
|
||||
"code": "cyber_policy"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::BAD_REQUEST,
|
||||
url: Some("ws://example.com/v1/responses".to_string()),
|
||||
headers: None,
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
let CodexErr::CyberPolicy { message } = err else {
|
||||
panic!("expected CodexErr::CyberPolicy, got {err:?}");
|
||||
};
|
||||
assert_eq!(message, "This websocket request was flagged.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"code": "cyber_policy"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::BAD_REQUEST,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: None,
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
let CodexErr::CyberPolicy { message } = err else {
|
||||
panic!("expected CodexErr::CyberPolicy, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
message,
|
||||
"This request has been flagged for possible cybersecurity risk."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_keeps_unknown_400_errors_generic() {
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"message": "Some other bad request.",
|
||||
"code": "some_other_policy"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::BAD_REQUEST,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: None,
|
||||
body: Some(body.clone()),
|
||||
}));
|
||||
|
||||
let CodexErr::InvalidRequest(message) = err else {
|
||||
panic!("expected CodexErr::InvalidRequest, got {err:?}");
|
||||
};
|
||||
assert_eq!(message, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_usage_limit_limit_name_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::Verbosity as VerbosityConfig;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::ModelVerification;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
@@ -71,6 +72,8 @@ pub enum ResponseEvent {
|
||||
/// Emitted when the server includes `OpenAI-Model` on the stream response.
|
||||
/// This can differ from the requested model when backend safety routing applies.
|
||||
ServerModel(String),
|
||||
/// Emitted when the server recommends additional account verification.
|
||||
ModelVerifications(Vec<ModelVerification>),
|
||||
/// Emitted when `X-Reasoning-Included: true` is present on the response,
|
||||
/// meaning the server already accounted for past reasoning tokens and the
|
||||
/// client should not re-estimate them.
|
||||
|
||||
@@ -608,6 +608,7 @@ async fn run_websocket_response_stream(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let model_verifications = event.model_verifications();
|
||||
if event.kind() == "codex.rate_limits" {
|
||||
if let Some(snapshot) = parse_rate_limit_event(&text) {
|
||||
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
|
||||
@@ -622,6 +623,16 @@ async fn run_websocket_response_stream(
|
||||
.await;
|
||||
last_server_model = Some(model);
|
||||
}
|
||||
if let Some(verifications) = model_verifications
|
||||
&& tx_event
|
||||
.send(Ok(ResponseEvent::ModelVerifications(verifications)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(ApiError::Stream(
|
||||
"response event consumer dropped".to_string(),
|
||||
));
|
||||
}
|
||||
match process_responses_event(event) {
|
||||
Ok(Some(event)) => {
|
||||
let is_completed = matches!(event, ResponseEvent::Completed { .. });
|
||||
|
||||
@@ -27,6 +27,8 @@ pub enum ApiError {
|
||||
RateLimit(String),
|
||||
#[error("invalid request: {message}")]
|
||||
InvalidRequest { message: String },
|
||||
#[error("cyber policy: {message}")]
|
||||
CyberPolicy { message: String },
|
||||
#[error("server overloaded")]
|
||||
ServerOverloaded,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_client::ByteStream;
|
||||
use codex_client::StreamResponse;
|
||||
use codex_client::TransportError;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ModelVerification;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
@@ -27,6 +28,7 @@ use tracing::trace;
|
||||
|
||||
const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included";
|
||||
const OPENAI_MODEL_HEADER: &str = "openai-model";
|
||||
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
|
||||
|
||||
/// Streams SSE events from an on-disk fixture for tests.
|
||||
pub fn stream_from_fixture(
|
||||
@@ -165,6 +167,7 @@ pub struct ResponsesStreamEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: String,
|
||||
headers: Option<Value>,
|
||||
metadata: Option<Value>,
|
||||
response: Option<Value>,
|
||||
item: Option<Value>,
|
||||
item_id: Option<String>,
|
||||
@@ -183,8 +186,7 @@ impl ResponsesStreamEvent {
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. `response.headers` for standard Responses stream events.
|
||||
/// 2. top-level `headers` for websocket metadata events (for example
|
||||
/// `codex.response.metadata`).
|
||||
/// 2. top-level `headers` for websocket metadata events.
|
||||
pub fn response_model(&self) -> Option<String> {
|
||||
let response_headers_model = self
|
||||
.response
|
||||
@@ -200,6 +202,17 @@ impl ResponsesStreamEvent {
|
||||
.and_then(header_openai_model_value_from_json),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn model_verifications(&self) -> Option<Vec<ModelVerification>> {
|
||||
if self.kind() != "response.metadata" {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("openai_verification_recommendation"))
|
||||
.and_then(model_verifications_from_json_value)
|
||||
}
|
||||
}
|
||||
|
||||
fn header_openai_model_value_from_json(value: &Value) -> Option<String> {
|
||||
@@ -214,6 +227,38 @@ fn header_openai_model_value_from_json(value: &Value) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn model_verifications_from_json_value(value: &Value) -> Option<Vec<ModelVerification>> {
|
||||
let verifications = value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
let mut verifications = Vec::new();
|
||||
for verification in items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter_map(parse_model_verification)
|
||||
{
|
||||
if !verifications.contains(&verification) {
|
||||
verifications.push(verification);
|
||||
}
|
||||
}
|
||||
verifications
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if verifications.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(verifications)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_model_verification(value: &str) -> Option<ModelVerification> {
|
||||
match value {
|
||||
TRUSTED_ACCESS_FOR_CYBER_VERIFICATION => Some(ModelVerification::TrustedAccessForCyber),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_as_string(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(value) => Some(value.clone()),
|
||||
@@ -296,6 +341,9 @@ pub fn process_responses_event(
|
||||
response_error = ApiError::QuotaExceeded;
|
||||
} else if is_usage_not_included(&error) {
|
||||
response_error = ApiError::UsageNotIncluded;
|
||||
} else if is_cyber_policy_error(&error) {
|
||||
let message = cyber_policy_message(error.message);
|
||||
response_error = ApiError::CyberPolicy { message };
|
||||
} else if is_invalid_prompt_error(&error) {
|
||||
let message = error
|
||||
.message
|
||||
@@ -414,6 +462,7 @@ pub async fn process_sse(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let model_verifications = event.model_verifications();
|
||||
|
||||
if let Some(model) = event.response_model()
|
||||
&& last_server_model.as_deref() != Some(model.as_str())
|
||||
@@ -427,6 +476,14 @@ pub async fn process_sse(
|
||||
}
|
||||
last_server_model = Some(model);
|
||||
}
|
||||
if let Some(verifications) = model_verifications
|
||||
&& tx_event
|
||||
.send(Ok(ResponseEvent::ModelVerifications(verifications)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
match process_responses_event(event) {
|
||||
Ok(Some(event)) => {
|
||||
@@ -488,11 +545,25 @@ fn is_invalid_prompt_error(error: &Error) -> bool {
|
||||
error.code.as_deref() == Some("invalid_prompt")
|
||||
}
|
||||
|
||||
fn is_cyber_policy_error(error: &Error) -> bool {
|
||||
error.code.as_deref() == Some("cyber_policy")
|
||||
}
|
||||
|
||||
fn is_server_overloaded_error(error: &Error) -> bool {
|
||||
error.code.as_deref() == Some("server_is_overloaded")
|
||||
|| error.code.as_deref() == Some("slow_down")
|
||||
}
|
||||
|
||||
fn cyber_policy_fallback_message() -> String {
|
||||
"This request has been flagged for possible cybersecurity risk.".to_string()
|
||||
}
|
||||
|
||||
fn cyber_policy_message(message: Option<String>) -> String {
|
||||
message
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.unwrap_or_else(cyber_policy_fallback_message)
|
||||
}
|
||||
|
||||
fn rate_limit_regex() -> &'static regex_lite::Regex {
|
||||
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
|
||||
#[expect(clippy::unwrap_used)]
|
||||
@@ -841,6 +912,45 @@ mod tests {
|
||||
assert_matches!(events[0], Err(ApiError::QuotaExceeded));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cyber_policy_error_is_fatal() {
|
||||
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":"This request was flagged for cyber policy."},"incomplete_details":null}}"#;
|
||||
|
||||
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
match &events[0] {
|
||||
Err(ApiError::CyberPolicy { message }) => {
|
||||
assert_eq!(message, "This request was flagged for cyber policy.");
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cyber_policy_error_uses_fallback_for_empty_message() {
|
||||
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":" "},"incomplete_details":null}}"#;
|
||||
|
||||
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
match &events[0] {
|
||||
Err(ApiError::CyberPolicy { message }) => {
|
||||
assert_eq!(
|
||||
message,
|
||||
"This request has been flagged for possible cybersecurity risk."
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_prompt_without_type_is_invalid_request() {
|
||||
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#;
|
||||
@@ -975,6 +1085,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_response_stream_ignores_model_verification_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"openai-verification-recommendation",
|
||||
HeaderValue::from_static(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION),
|
||||
);
|
||||
let completed = json!({
|
||||
"type": "response.completed",
|
||||
"response": { "id": "resp-1" }
|
||||
});
|
||||
let sse = format!("event: response.completed\ndata: {completed}\n\n");
|
||||
let bytes = stream::iter(vec![Ok(Bytes::from(sse))]);
|
||||
let stream_response = StreamResponse {
|
||||
status: StatusCode::OK,
|
||||
headers,
|
||||
bytes: Box::pin(bytes),
|
||||
};
|
||||
|
||||
let mut stream = spawn_response_stream(
|
||||
stream_response,
|
||||
idle_timeout(),
|
||||
/*telemetry*/ None,
|
||||
/*turn_state*/ None,
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = stream.rx_event.recv().await {
|
||||
events.push(event.expect("expected ok event"));
|
||||
}
|
||||
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ResponseEvent::ModelVerifications(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_sse_ignores_response_model_field_in_payload() {
|
||||
let events = run_sse(vec![
|
||||
@@ -1042,10 +1189,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_sse_emits_model_verification_field() {
|
||||
let events = run_sse(vec![
|
||||
json!({
|
||||
"type": "response.metadata",
|
||||
"sequence_number": 1,
|
||||
"response_id": "resp-1",
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp-1"
|
||||
}
|
||||
}),
|
||||
])
|
||||
.await;
|
||||
|
||||
assert_matches!(
|
||||
&events[0],
|
||||
ResponseEvent::ModelVerifications(verifications)
|
||||
if verifications == &vec![ModelVerification::TrustedAccessForCyber]
|
||||
);
|
||||
assert_matches!(
|
||||
&events[1],
|
||||
ResponseEvent::Completed {
|
||||
response_id,
|
||||
token_usage: None
|
||||
} if response_id == "resp-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_stream_event_response_model_reads_top_level_headers() {
|
||||
let ev: ResponsesStreamEvent = serde_json::from_value(json!({
|
||||
"type": "codex.response.metadata",
|
||||
"type": "response.metadata",
|
||||
"headers": {
|
||||
"openai-model": CYBER_RESTRICTED_MODEL_FOR_TESTS,
|
||||
}
|
||||
@@ -1080,6 +1261,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_stream_event_model_verification_reads_metadata_field() {
|
||||
let event = json!({
|
||||
"type": "response.metadata",
|
||||
"sequence_number": 1,
|
||||
"response_id": "resp-1",
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]
|
||||
}
|
||||
});
|
||||
let event: ResponsesStreamEvent =
|
||||
serde_json::from_value(event).expect("expected event to deserialize");
|
||||
|
||||
assert_eq!(
|
||||
event.model_verifications(),
|
||||
Some(vec![ModelVerification::TrustedAccessForCyber])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_stream_event_model_verification_ignores_unknown_field() {
|
||||
let event = json!({
|
||||
"type": "response.metadata",
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": ["unknown"]
|
||||
}
|
||||
});
|
||||
let event: ResponsesStreamEvent =
|
||||
serde_json::from_value(event).expect("expected event to deserialize");
|
||||
|
||||
assert_eq!(event.model_verifications(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_stream_event_model_verification_ignores_non_array_field() {
|
||||
let event = json!({
|
||||
"type": "response.metadata",
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": TRUSTED_ACCESS_FOR_CYBER_VERIFICATION
|
||||
}
|
||||
});
|
||||
let event: ResponsesStreamEvent =
|
||||
serde_json::from_value(event).expect("expected event to deserialize");
|
||||
|
||||
assert_eq!(event.model_verifications(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_parse_retry_after() {
|
||||
let err = Error {
|
||||
|
||||
@@ -331,6 +331,8 @@ use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::ModelRerouteEvent;
|
||||
use codex_protocol::protocol::ModelRerouteReason;
|
||||
use codex_protocol::protocol::ModelVerification;
|
||||
use codex_protocol::protocol::ModelVerificationEvent;
|
||||
use codex_protocol::protocol::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -2385,6 +2387,18 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_model_verification(
|
||||
self: &Arc<Self>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
verifications: Vec<ModelVerification>,
|
||||
) {
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ModelVerification(ModelVerificationEvent { verifications }),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_history(
|
||||
&self,
|
||||
items: Vec<ResponseItem>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::turn_context::image_generation_tool_auth_allowed;
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// Spawn a review thread using the given prompt.
|
||||
pub(super) async fn spawn_review_thread(
|
||||
@@ -140,6 +141,8 @@ pub(super) async fn spawn_review_thread(
|
||||
turn_metadata_state,
|
||||
turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()),
|
||||
turn_timing_state: Arc::new(TurnTimingState::default()),
|
||||
server_model_warning_emitted: AtomicBool::new(false),
|
||||
model_verification_emitted: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
// Seed the child task with the review prompt as the initial user message.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::SkillInjections;
|
||||
use crate::SkillLoadOutcome;
|
||||
@@ -364,7 +365,6 @@ pub(crate) async fn run_turn(
|
||||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
|
||||
// many turns, from the perspective of the user, it is a single turn.
|
||||
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let mut server_model_warning_emitted_for_turn = false;
|
||||
|
||||
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
|
||||
// one instance across retries within this turn.
|
||||
@@ -455,7 +455,6 @@ pub(crate) async fn run_turn(
|
||||
sampling_request_input,
|
||||
&explicitly_enabled_connectors,
|
||||
skills_outcome,
|
||||
&mut server_model_warning_emitted_for_turn,
|
||||
cancellation_token.child_token(),
|
||||
)
|
||||
.await
|
||||
@@ -1022,7 +1021,6 @@ async fn run_sampling_request(
|
||||
input: Vec<ResponseItem>,
|
||||
explicitly_enabled_connectors: &HashSet<String>,
|
||||
skills_outcome: Option<&SkillLoadOutcome>,
|
||||
server_model_warning_emitted_for_turn: &mut bool,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> CodexResult<SamplingRequestResult> {
|
||||
let router = built_tools(
|
||||
@@ -1076,7 +1074,6 @@ async fn run_sampling_request(
|
||||
client_session,
|
||||
turn_metadata_header,
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
server_model_warning_emitted_for_turn,
|
||||
&prompt,
|
||||
cancellation_token.child_token(),
|
||||
)
|
||||
@@ -1490,6 +1487,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
|
||||
| EventMsg::RealtimeConversationRealtime(_)
|
||||
| EventMsg::RealtimeConversationClosed(_)
|
||||
| EventMsg::ModelReroute(_)
|
||||
| EventMsg::ModelVerification(_)
|
||||
| EventMsg::ContextCompacted(_)
|
||||
| EventMsg::ThreadRolledBack(_)
|
||||
| EventMsg::TurnStarted(_)
|
||||
@@ -1866,7 +1864,6 @@ async fn try_run_sampling_request(
|
||||
client_session: &mut ModelClientSession,
|
||||
turn_metadata_header: Option<&str>,
|
||||
turn_diff_tracker: SharedTurnDiffTracker,
|
||||
server_model_warning_emitted_for_turn: &mut bool,
|
||||
prompt: &Prompt,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> CodexResult<SamplingRequestResult> {
|
||||
@@ -2099,12 +2096,25 @@ async fn try_run_sampling_request(
|
||||
}
|
||||
}
|
||||
ResponseEvent::ServerModel(server_model) => {
|
||||
if !*server_model_warning_emitted_for_turn
|
||||
if !turn_context
|
||||
.server_model_warning_emitted
|
||||
.load(Ordering::Relaxed)
|
||||
&& sess
|
||||
.maybe_warn_on_server_model_mismatch(&turn_context, server_model)
|
||||
.await
|
||||
{
|
||||
*server_model_warning_emitted_for_turn = true;
|
||||
turn_context
|
||||
.server_model_warning_emitted
|
||||
.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
ResponseEvent::ModelVerifications(verifications) => {
|
||||
if !turn_context
|
||||
.model_verification_emitted
|
||||
.swap(true, Ordering::Relaxed)
|
||||
{
|
||||
sess.emit_model_verification(&turn_context, verifications)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ResponseEvent::ServerReasoningIncluded(included) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ use codex_model_provider::SharedModelProvider;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool {
|
||||
matches!(
|
||||
@@ -82,6 +84,8 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
|
||||
pub(crate) turn_skills: TurnSkillsContext,
|
||||
pub(crate) turn_timing_state: Arc<TurnTimingState>,
|
||||
pub(crate) server_model_warning_emitted: AtomicBool,
|
||||
pub(crate) model_verification_emitted: AtomicBool,
|
||||
}
|
||||
impl TurnContext {
|
||||
pub(crate) fn permission_profile(&self) -> PermissionProfile {
|
||||
@@ -216,6 +220,12 @@ impl TurnContext {
|
||||
turn_metadata_state: self.turn_metadata_state.clone(),
|
||||
turn_skills: self.turn_skills.clone(),
|
||||
turn_timing_state: Arc::clone(&self.turn_timing_state),
|
||||
server_model_warning_emitted: AtomicBool::new(
|
||||
self.server_model_warning_emitted.load(Ordering::Relaxed),
|
||||
),
|
||||
model_verification_emitted: AtomicBool::new(
|
||||
self.model_verification_emitted.load(Ordering::Relaxed),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +479,8 @@ impl Session {
|
||||
turn_metadata_state,
|
||||
turn_skills: TurnSkillsContext::new(skills_outcome),
|
||||
turn_timing_state: Arc::new(TurnTimingState::default()),
|
||||
server_model_warning_emitted: AtomicBool::new(false),
|
||||
model_verification_emitted: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool {
|
||||
| ResponseEvent::ReasoningContentDelta { .. } => true,
|
||||
ResponseEvent::Created
|
||||
| ResponseEvent::ServerModel(_)
|
||||
| ResponseEvent::ModelVerifications(_)
|
||||
| ResponseEvent::ServerReasoningIncluded(_)
|
||||
| ResponseEvent::ToolCallInputDelta { .. }
|
||||
| ResponseEvent::Completed { .. }
|
||||
|
||||
@@ -632,6 +632,17 @@ pub fn ev_response_created(id: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_model_verification_metadata(id: &str, verifications: Vec<&str>) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.metadata",
|
||||
"sequence_number": 1,
|
||||
"response_id": id,
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": verifications,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_completed_with_tokens(id: &str, total_tokens: i64) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.completed",
|
||||
|
||||
@@ -2,13 +2,16 @@ use anyhow::Result;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ModelRerouteReason;
|
||||
use codex_protocol::protocol::ModelVerification;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_model_verification_metadata;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_response_once;
|
||||
use core_test_support::responses::mount_response_sequence;
|
||||
@@ -20,9 +23,14 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
const SERVER_MODEL: &str = "gpt-5.2";
|
||||
const REQUESTED_MODEL: &str = "gpt-5.3-codex";
|
||||
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
|
||||
|
||||
const CYBER_POLICY_MESSAGE: &str =
|
||||
"This request has been flagged for potentially high-risk cyber activity.";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> Result<()> {
|
||||
@@ -113,6 +121,57 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cyber_policy_response_emits_typed_error_without_retry() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": CYBER_POLICY_MESSAGE,
|
||||
"type": "invalid_request",
|
||||
"param": null,
|
||||
"code": "cyber_policy"
|
||||
}
|
||||
}));
|
||||
let mock = mount_response_once(&server, response).await;
|
||||
|
||||
let mut builder = test_codex().with_model(REQUESTED_MODEL);
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
items: vec![UserInput::Text {
|
||||
text: "trigger cyber policy error".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let error = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await;
|
||||
let EventMsg::Error(error) = error else {
|
||||
panic!("expected error event");
|
||||
};
|
||||
assert_eq!(error.message, CYBER_POLICY_MESSAGE);
|
||||
assert_eq!(error.codex_error_info, Some(CodexErrorInfo::CyberPolicy));
|
||||
|
||||
mock.single_request();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn response_model_field_mismatch_emits_warning_when_header_matches_requested() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -318,3 +377,150 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()>
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_verification_emits_structured_event_without_reroute_or_warning() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let response = sse_response(sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]),
|
||||
core_test_support::responses::ev_completed("resp-1"),
|
||||
]));
|
||||
let _mock = mount_response_once(&server, response).await;
|
||||
|
||||
let mut builder = test_codex().with_model(REQUESTED_MODEL);
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
items: vec![UserInput::Text {
|
||||
text: "trigger model verification".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut verification_count = 0;
|
||||
let mut reroute_count = 0;
|
||||
let mut warning_count = 0;
|
||||
let mut warning_item_count = 0;
|
||||
loop {
|
||||
let event = wait_for_event(&test.codex, |_| true).await;
|
||||
match event {
|
||||
EventMsg::ModelVerification(event) => {
|
||||
assert_eq!(
|
||||
event.verifications,
|
||||
vec![ModelVerification::TrustedAccessForCyber]
|
||||
);
|
||||
verification_count += 1;
|
||||
}
|
||||
EventMsg::Warning(_) => warning_count += 1,
|
||||
EventMsg::ModelReroute(_) => reroute_count += 1,
|
||||
EventMsg::RawResponseItem(raw)
|
||||
if matches!(
|
||||
&raw.item,
|
||||
ResponseItem::Message { content, .. }
|
||||
if content.iter().any(|item| matches!(
|
||||
item,
|
||||
ContentItem::InputText { text } if text.starts_with("Warning: ")
|
||||
))
|
||||
) =>
|
||||
{
|
||||
warning_item_count += 1;
|
||||
}
|
||||
EventMsg::TurnComplete(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(verification_count, 1);
|
||||
assert_eq!(reroute_count, 0);
|
||||
assert_eq!(warning_count, 0);
|
||||
assert_eq!(warning_item_count, 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_verification_only_emits_once_per_turn() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let tool_args = serde_json::json!({
|
||||
"command": "echo hello",
|
||||
"timeout_ms": 1_000
|
||||
});
|
||||
|
||||
let first_response = sse_response(sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
"call-1",
|
||||
"shell_command",
|
||||
&serde_json::to_string(&tool_args)?,
|
||||
),
|
||||
ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]),
|
||||
core_test_support::responses::ev_completed("resp-1"),
|
||||
]));
|
||||
let second_response = sse_response(sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_model_verification_metadata("resp-2", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
core_test_support::responses::ev_completed("resp-2"),
|
||||
]));
|
||||
let _mock = mount_response_sequence(&server, vec![first_response, second_response]).await;
|
||||
|
||||
let mut builder = test_codex().with_model(REQUESTED_MODEL);
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
items: vec![UserInput::Text {
|
||||
text: "trigger follow-up model verification".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut verification_count = 0;
|
||||
loop {
|
||||
let event = wait_for_event(&test.codex, |_| true).await;
|
||||
match event {
|
||||
EventMsg::ModelVerification(_) => verification_count += 1,
|
||||
EventMsg::Warning(warning) if warning.message.contains("high-risk cyber activity") => {
|
||||
panic!("model verification should not emit a warning event");
|
||||
}
|
||||
EventMsg::TurnComplete(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(verification_count, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -292,6 +292,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
);
|
||||
CodexStatus::Running
|
||||
}
|
||||
ServerNotification::ModelVerification(_) => CodexStatus::Running,
|
||||
ServerNotification::ThreadTokenUsageUpdated(notification) => {
|
||||
self.last_total_token_usage = Some(notification.token_usage);
|
||||
CodexStatus::Running
|
||||
|
||||
@@ -489,6 +489,7 @@ impl EventProcessorWithJsonOutput {
|
||||
}));
|
||||
CodexStatus::Running
|
||||
}
|
||||
ServerNotification::ModelVerification(_) => CodexStatus::Running,
|
||||
ServerNotification::ThreadTokenUsageUpdated(notification) => {
|
||||
self.last_total_token_usage = Some(notification.token_usage);
|
||||
CodexStatus::Running
|
||||
|
||||
@@ -1138,6 +1138,9 @@ fn should_process_notification(
|
||||
ServerNotification::ModelRerouted(notification) => {
|
||||
notification.thread_id == thread_id && notification.turn_id == turn_id
|
||||
}
|
||||
ServerNotification::ModelVerification(notification) => {
|
||||
notification.thread_id == thread_id && notification.turn_id == turn_id
|
||||
}
|
||||
ServerNotification::ThreadTokenUsageUpdated(notification) => {
|
||||
notification.thread_id == thread_id && notification.turn_id == turn_id
|
||||
}
|
||||
|
||||
@@ -263,7 +263,9 @@ async fn run_codex_tool_session_inner(
|
||||
outgoing.send_response(request_id.clone(), result).await;
|
||||
break;
|
||||
}
|
||||
EventMsg::Warning(_) | EventMsg::GuardianWarning(_) => {
|
||||
EventMsg::Warning(_)
|
||||
| EventMsg::GuardianWarning(_)
|
||||
| EventMsg::ModelVerification(_) => {
|
||||
continue;
|
||||
}
|
||||
EventMsg::GuardianAssessment(_) => {
|
||||
|
||||
@@ -1058,6 +1058,7 @@ impl SessionTelemetry {
|
||||
"reasoning_summary_part_added".into()
|
||||
}
|
||||
ResponseEvent::ServerModel(_) => "server_model".into(),
|
||||
ResponseEvent::ModelVerifications(_) => "model_verifications".into(),
|
||||
ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(),
|
||||
ResponseEvent::RateLimits(_) => "rate_limits".into(),
|
||||
ResponseEvent::ModelsEtag(_) => "models_etag".into(),
|
||||
|
||||
@@ -110,6 +110,8 @@ pub enum CodexErr {
|
||||
UsageLimitReached(UsageLimitReachedError),
|
||||
#[error("Selected model is at capacity. Please try a different model.")]
|
||||
ServerOverloaded,
|
||||
#[error("{message}")]
|
||||
CyberPolicy { message: String },
|
||||
#[error("{0}")]
|
||||
ResponseStreamFailed(ResponseStreamFailed),
|
||||
#[error("{0}")]
|
||||
@@ -186,7 +188,8 @@ impl CodexErr {
|
||||
| CodexErr::Spawn
|
||||
| CodexErr::SessionConfiguredNotFirstEvent
|
||||
| CodexErr::UsageLimitReached(_)
|
||||
| CodexErr::ServerOverloaded => false,
|
||||
| CodexErr::ServerOverloaded
|
||||
| CodexErr::CyberPolicy { .. } => false,
|
||||
CodexErr::Stream(..)
|
||||
| CodexErr::Timeout
|
||||
| CodexErr::UnexpectedStatus(_)
|
||||
@@ -217,6 +220,7 @@ impl CodexErr {
|
||||
| CodexErr::QuotaExceeded
|
||||
| CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
|
||||
CodexErr::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
|
||||
CodexErr::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy,
|
||||
CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
|
||||
http_status_code: self.http_status_code_value(),
|
||||
},
|
||||
|
||||
@@ -1538,6 +1538,9 @@ pub enum EventMsg {
|
||||
/// Model routing changed from the requested model to a different model.
|
||||
ModelReroute(ModelRerouteEvent),
|
||||
|
||||
/// Backend recommends additional account verification for this turn.
|
||||
ModelVerification(ModelVerificationEvent),
|
||||
|
||||
/// Conversation history was compacted (either automatically or manually).
|
||||
ContextCompacted(ContextCompactedEvent),
|
||||
|
||||
@@ -1964,6 +1967,7 @@ pub enum CodexErrorInfo {
|
||||
ContextWindowExceeded,
|
||||
UsageLimitExceeded,
|
||||
ServerOverloaded,
|
||||
CyberPolicy,
|
||||
HttpConnectionFailed {
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
@@ -2000,6 +2004,7 @@ impl CodexErrorInfo {
|
||||
Self::ContextWindowExceeded
|
||||
| Self::UsageLimitExceeded
|
||||
| Self::ServerOverloaded
|
||||
| Self::CyberPolicy
|
||||
| Self::HttpConnectionFailed { .. }
|
||||
| Self::ResponseStreamConnectionFailed { .. }
|
||||
| Self::InternalServerError
|
||||
@@ -2183,6 +2188,18 @@ pub struct ModelRerouteEvent {
|
||||
pub reason: ModelRerouteReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum ModelVerification {
|
||||
TrustedAccessForCyber,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct ModelVerificationEvent {
|
||||
pub verifications: Vec<ModelVerification>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ContextCompactedEvent;
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String {
|
||||
ApiError::Retryable { .. } => "retryable error".to_string(),
|
||||
ApiError::RateLimit(_) => "rate limit".to_string(),
|
||||
ApiError::InvalidRequest { .. } => "invalid request".to_string(),
|
||||
ApiError::CyberPolicy { .. } => "cyber policy".to_string(),
|
||||
ApiError::ServerOverloaded => "server overloaded".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
| EventMsg::RealtimeConversationRealtime(_)
|
||||
| EventMsg::RealtimeConversationClosed(_)
|
||||
| EventMsg::ModelReroute(_)
|
||||
| EventMsg::ModelVerification(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
|
||||
@@ -400,6 +400,9 @@ fn server_notification_thread_target(
|
||||
}
|
||||
ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()),
|
||||
ServerNotification::ModelVerification(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
ServerNotification::ThreadRealtimeStarted(notification) => {
|
||||
Some(notification.thread_id.as_str())
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::McpServerStartupState;
|
||||
use codex_app_server_protocol::McpServerStatusDetail;
|
||||
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||||
use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
@@ -194,6 +195,8 @@ use codex_protocol::protocol::McpStartupStatus;
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use codex_protocol::protocol::McpToolCallBeginEvent;
|
||||
use codex_protocol::protocol::McpToolCallEndEvent;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::ModelVerification as CoreModelVerification;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::PatchApplyBeginEvent;
|
||||
use codex_protocol::protocol::RateLimitReachedType;
|
||||
@@ -256,6 +259,7 @@ const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?";
|
||||
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
|
||||
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
|
||||
const MULTI_AGENT_ENABLE_NOTICE: &str = "Subagents will be enabled in the next session.";
|
||||
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING: &str = "Your account was flagged for potentially high-risk cyber activity. Requests may be slower while additional verification is applied. To regain faster access, apply for trusted access: https://chatgpt.com/cyber or learn more: https://developers.openai.com/codex/concepts/cyber-safety";
|
||||
const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories";
|
||||
const MEMORIES_ENABLE_TITLE: &str = "Enable memories?";
|
||||
const MEMORIES_ENABLE_YES: &str = "Yes, enable";
|
||||
@@ -651,6 +655,15 @@ fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option<Ra
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn is_core_cyber_policy_error(info: &CoreCodexErrorInfo) -> bool {
|
||||
matches!(info, CoreCodexErrorInfo::CyberPolicy)
|
||||
}
|
||||
|
||||
fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
|
||||
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum ExternalEditorState {
|
||||
#[default]
|
||||
@@ -3040,6 +3053,16 @@ impl ChatWidget {
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
|
||||
fn on_cyber_policy_error(&mut self) {
|
||||
self.submit_pending_steers_after_interrupt = false;
|
||||
self.finalize_turn();
|
||||
self.add_to_history(history_cell::new_cyber_policy_error_event());
|
||||
self.request_redraw();
|
||||
|
||||
// After an error ends the turn, try sending the next queued input.
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
|
||||
fn workspace_owner_usage_nudge_enabled(&self) -> bool {
|
||||
self.config
|
||||
.features
|
||||
@@ -3105,6 +3128,11 @@ impl ChatWidget {
|
||||
.as_ref()
|
||||
.is_some_and(|info| self.handle_app_server_steer_rejected_error(info))
|
||||
{
|
||||
} else if codex_error_info
|
||||
.as_ref()
|
||||
.is_some_and(is_app_server_cyber_policy_error)
|
||||
{
|
||||
self.on_cyber_policy_error();
|
||||
} else if let Some(info) = codex_error_info
|
||||
.as_ref()
|
||||
.and_then(app_server_rate_limit_error_kind)
|
||||
@@ -3125,6 +3153,19 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn on_core_model_verification(&mut self, verifications: &[CoreModelVerification]) {
|
||||
if verifications.contains(&CoreModelVerification::TrustedAccessForCyber) {
|
||||
self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) {
|
||||
if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) {
|
||||
self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one MCP startup update, promoting it into either the active startup
|
||||
/// round or a buffered "next" round.
|
||||
///
|
||||
@@ -6579,6 +6620,9 @@ impl ChatWidget {
|
||||
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
|
||||
}
|
||||
ServerNotification::ModelRerouted(_) => {}
|
||||
ServerNotification::ModelVerification(notification) => {
|
||||
self.on_app_server_model_verification(¬ification.verifications)
|
||||
}
|
||||
ServerNotification::Warning(notification) => self.on_warning(notification.message),
|
||||
ServerNotification::GuardianWarning(notification) => {
|
||||
self.on_warning(notification.message)
|
||||
@@ -7084,6 +7128,9 @@ impl ChatWidget {
|
||||
| EventMsg::GuardianWarning(WarningEvent { message }) => self.on_warning(message),
|
||||
EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev),
|
||||
EventMsg::ModelReroute(_) => {}
|
||||
EventMsg::ModelVerification(event) => {
|
||||
self.on_core_model_verification(&event.verifications)
|
||||
}
|
||||
EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info,
|
||||
@@ -7092,6 +7139,11 @@ impl ChatWidget {
|
||||
.as_ref()
|
||||
.is_some_and(|info| self.handle_steer_rejected_error(info))
|
||||
{
|
||||
} else if codex_error_info
|
||||
.as_ref()
|
||||
.is_some_and(is_core_cyber_policy_error)
|
||||
{
|
||||
self.on_cyber_policy_error();
|
||||
} else if let Some(kind) = codex_error_info
|
||||
.as_ref()
|
||||
.and_then(core_rate_limit_error_kind)
|
||||
|
||||
@@ -70,6 +70,8 @@ pub(super) use codex_app_server_protocol::MarketplaceInterface;
|
||||
pub(super) use codex_app_server_protocol::McpServerStartupState;
|
||||
pub(super) use codex_app_server_protocol::McpServerStatusDetail;
|
||||
pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||||
pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
|
||||
pub(super) use codex_app_server_protocol::ModelVerificationNotification;
|
||||
pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus;
|
||||
pub(super) use codex_app_server_protocol::PatchChangeKind;
|
||||
pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams;
|
||||
@@ -147,6 +149,7 @@ pub(super) use codex_protocol::protocol::CodexErrorInfo;
|
||||
pub(super) use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
|
||||
pub(super) use codex_protocol::protocol::CollabAgentSpawnEndEvent;
|
||||
pub(super) use codex_protocol::protocol::CreditsSnapshot;
|
||||
pub(super) use codex_protocol::protocol::ErrorEvent;
|
||||
pub(super) use codex_protocol::protocol::Event;
|
||||
pub(super) use codex_protocol::protocol::EventMsg;
|
||||
pub(super) use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
@@ -169,6 +172,8 @@ pub(super) use codex_protocol::protocol::ItemCompletedEvent;
|
||||
pub(super) use codex_protocol::protocol::McpStartupCompleteEvent;
|
||||
pub(super) use codex_protocol::protocol::McpStartupStatus;
|
||||
pub(super) use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
pub(super) use codex_protocol::protocol::ModelVerification as CoreModelVerification;
|
||||
pub(super) use codex_protocol::protocol::ModelVerificationEvent;
|
||||
pub(super) use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
pub(super) use codex_protocol::protocol::Op;
|
||||
pub(super) use codex_protocol::protocol::PatchApplyBeginEvent;
|
||||
|
||||
@@ -678,6 +678,71 @@ async fn live_app_server_server_overloaded_error_renders_warning() {
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: AppServerTurn {
|
||||
id: "turn-1".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: Some(0),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::Error(ErrorNotification {
|
||||
error: AppServerTurnError {
|
||||
message: "server fallback message".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::CyberPolicy.into()),
|
||||
additional_details: None,
|
||||
},
|
||||
will_retry: false,
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(rendered.contains("This chat was flagged for possible cybersecurity risk"));
|
||||
assert!(rendered.contains("Trusted Access for Cyber"));
|
||||
assert!(!rendered.contains("server fallback message"));
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_app_server_model_verification_renders_warning() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelVerification(ModelVerificationNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
verifications: vec![AppServerModelVerification::TrustedAccessForCyber],
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(rendered.contains("flagged for potentially high-risk cyber activity"));
|
||||
assert!(rendered.contains("slower while additional verification"));
|
||||
assert!(rendered.contains("https://chatgpt.com/cyber"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_app_server_invalid_thread_name_update_is_ignored() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -28,6 +28,45 @@ async fn token_count_none_resets_context_indicator() {
|
||||
assert_eq!(chat.bottom_pane.context_window_percent(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_cyber_policy_error_renders_dedicated_notice() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "cyber-policy".into(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "server fallback message".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::CyberPolicy),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(rendered.contains("This chat was flagged for possible cybersecurity risk"));
|
||||
assert!(rendered.contains("Trusted Access for Cyber"));
|
||||
assert!(!rendered.contains("server fallback message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_model_verification_renders_warning() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "model-verification".into(),
|
||||
msg: EventMsg::ModelVerification(ModelVerificationEvent {
|
||||
verifications: vec![CoreModelVerification::TrustedAccessForCyber],
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(rendered.contains("flagged for potentially high-risk cyber activity"));
|
||||
assert!(rendered.contains("slower while additional verification"));
|
||||
assert!(rendered.contains("https://chatgpt.com/cyber"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn context_indicator_shows_used_tokens_when_window_unknown() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await;
|
||||
|
||||
@@ -1832,6 +1832,50 @@ pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
|
||||
PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ")
|
||||
}
|
||||
|
||||
const TRUSTED_ACCESS_FOR_CYBER_URL: &str = "https://chatgpt.com/cyber";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CyberPolicyNoticeCell;
|
||||
|
||||
pub(crate) fn new_cyber_policy_error_event() -> CyberPolicyNoticeCell {
|
||||
CyberPolicyNoticeCell
|
||||
}
|
||||
|
||||
impl HistoryCell for CyberPolicyNoticeCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(
|
||||
vec![
|
||||
"ⓘ ".cyan(),
|
||||
"This chat was flagged for possible cybersecurity risk".bold(),
|
||||
]
|
||||
.into(),
|
||||
);
|
||||
|
||||
let wrap_width = width.saturating_sub(2).max(1) as usize;
|
||||
let body = Line::from(vec![
|
||||
" If this seems wrong, try rephrasing your request. To get authorized for security work, join the "
|
||||
.dim(),
|
||||
"Trusted Access for Cyber".cyan().underlined(),
|
||||
" program.".dim(),
|
||||
]);
|
||||
let wrapped = adaptive_wrap_line(
|
||||
&body,
|
||||
RtOptions::new(wrap_width).subsequent_indent(" ".into()),
|
||||
);
|
||||
push_owned_lines(&wrapped, &mut lines);
|
||||
lines.push(
|
||||
vec![
|
||||
" ".into(),
|
||||
TRUSTED_ACCESS_FOR_CYBER_URL.cyan().underlined(),
|
||||
]
|
||||
.into(),
|
||||
);
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DeprecationNoticeCell {
|
||||
summary: String,
|
||||
@@ -3255,6 +3299,20 @@ mod tests {
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyber_policy_error_event_snapshot() {
|
||||
let cell = new_cyber_policy_error_event();
|
||||
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyber_policy_error_event_narrow_snapshot() {
|
||||
let cell = new_cyber_policy_error_event();
|
||||
let rendered = render_lines(&cell.display_lines(/*width*/ 36)).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_output_long_command_snapshot() {
|
||||
let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 3312
|
||||
expression: rendered
|
||||
---
|
||||
ⓘ This chat was flagged for possible cybersecurity risk
|
||||
If this seems wrong, try
|
||||
rephrasing your request. To get
|
||||
authorized for security work,
|
||||
join the Trusted Access for
|
||||
Cyber program.
|
||||
https://chatgpt.com/cyber
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 3305
|
||||
expression: rendered
|
||||
---
|
||||
ⓘ This chat was flagged for possible cybersecurity risk
|
||||
If this seems wrong, try rephrasing your request. To get authorized for
|
||||
security work, join the Trusted Access for Cyber program.
|
||||
https://chatgpt.com/cyber
|
||||
Reference in New Issue
Block a user