mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Merge 9e7f13ca34 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -8885,7 +8885,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a
|
||||
assert_eq!(complete_output["remainingTokens"], 0);
|
||||
assert_eq!(
|
||||
complete_output["completionBudgetReport"],
|
||||
"Goal achieved. Report final budget usage to the user: tokens used: 580 of 500."
|
||||
"Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language."
|
||||
);
|
||||
let requests = responses.requests();
|
||||
let completion_followup_request = requests
|
||||
|
||||
@@ -87,20 +87,13 @@ fn goal_response(
|
||||
}
|
||||
|
||||
fn completion_budget_report(goal: &ThreadGoal) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(budget) = goal.token_budget {
|
||||
parts.push(format!("tokens used: {} of {budget}", goal.tokens_used));
|
||||
}
|
||||
if goal.time_used_seconds > 0 {
|
||||
parts.push(format!("time used: {} seconds", goal.time_used_seconds));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
if goal.token_budget.is_none() && goal.time_used_seconds <= 0 {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"Goal achieved. Report final budget usage to the user: {}.",
|
||||
parts.join("; ")
|
||||
))
|
||||
Some(
|
||||
"Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +124,7 @@ mod tests {
|
||||
goal: Some(goal),
|
||||
remaining_tokens: Some(6_750),
|
||||
completion_budget_report: Some(
|
||||
"Goal achieved. Report final budget usage to the user: tokens used: 3250 of 10000; time used: 75 seconds."
|
||||
"Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language."
|
||||
.to_string()
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import NoReturn
|
||||
|
||||
from .generated.v2_all import (
|
||||
ApprovalsReviewer,
|
||||
AskForApproval,
|
||||
AskForApprovalValue,
|
||||
)
|
||||
|
||||
|
||||
class ApprovalMode(str, Enum):
|
||||
"""High-level approval behavior for escalated permission requests."""
|
||||
|
||||
deny_all = "deny_all"
|
||||
auto_review = "auto_review"
|
||||
|
||||
|
||||
def _approval_mode_settings(
|
||||
approval_mode: ApprovalMode,
|
||||
) -> tuple[AskForApproval, ApprovalsReviewer | None]:
|
||||
"""Map the public approval mode to generated app-server start params."""
|
||||
if not isinstance(approval_mode, ApprovalMode):
|
||||
supported = ", ".join(mode.value for mode in ApprovalMode)
|
||||
raise ValueError(f"approval_mode must be one of: {supported}")
|
||||
|
||||
match approval_mode:
|
||||
case ApprovalMode.auto_review:
|
||||
return (
|
||||
AskForApproval(root=AskForApprovalValue.on_request),
|
||||
ApprovalsReviewer.auto_review,
|
||||
)
|
||||
case ApprovalMode.deny_all:
|
||||
return AskForApproval(root=AskForApprovalValue.never), None
|
||||
case _:
|
||||
return _assert_never_approval_mode(approval_mode)
|
||||
|
||||
|
||||
def _assert_never_approval_mode(approval_mode: NoReturn) -> NoReturn:
|
||||
"""Make approval mode mapping exhaustive for static type checkers."""
|
||||
raise AssertionError(f"Unhandled approval mode: {approval_mode!r}")
|
||||
|
||||
|
||||
def _approval_mode_override_settings(
|
||||
approval_mode: ApprovalMode | None,
|
||||
) -> tuple[AskForApproval | None, ApprovalsReviewer | None]:
|
||||
"""Map an optional public approval mode to app-server override params."""
|
||||
if approval_mode is None:
|
||||
return None, None
|
||||
return _approval_mode_settings(approval_mode)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import InitializeResponse, ServerInfo
|
||||
|
||||
|
||||
def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]:
|
||||
raw = user_agent.strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
if "/" in raw:
|
||||
name, version = raw.split("/", 1)
|
||||
return (name or None), (version or None)
|
||||
parts = raw.split(maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return raw, None
|
||||
|
||||
|
||||
def validate_initialize_metadata(payload: InitializeResponse) -> InitializeResponse:
|
||||
user_agent = (payload.userAgent or "").strip()
|
||||
server = payload.serverInfo
|
||||
|
||||
server_name: str | None = None
|
||||
server_version: str | None = None
|
||||
|
||||
if server is not None:
|
||||
server_name = (server.name or "").strip() or None
|
||||
server_version = (server.version or "").strip() or None
|
||||
|
||||
if (server_name is None or server_version is None) and user_agent:
|
||||
parsed_name, parsed_version = _split_user_agent(user_agent)
|
||||
if server_name is None:
|
||||
server_name = parsed_name
|
||||
if server_version is None:
|
||||
server_version = parsed_version
|
||||
|
||||
normalized_server_name = (server_name or "").strip()
|
||||
normalized_server_version = (server_version or "").strip()
|
||||
if not user_agent or not normalized_server_name or not normalized_server_version:
|
||||
raise RuntimeError(
|
||||
"initialize response missing required metadata "
|
||||
f"(user_agent={user_agent!r}, server_name={normalized_server_name!r}, server_version={normalized_server_version!r})"
|
||||
)
|
||||
|
||||
if server is None:
|
||||
payload.serverInfo = ServerInfo(
|
||||
name=normalized_server_name,
|
||||
version=normalized_server_version,
|
||||
)
|
||||
else:
|
||||
server.name = normalized_server_name
|
||||
server.version = normalized_server_version
|
||||
|
||||
return payload
|
||||
@@ -2,9 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import AsyncIterator, Iterator, NoReturn
|
||||
from typing import AsyncIterator, Iterator
|
||||
|
||||
from ._approval_mode import (
|
||||
ApprovalMode as ApprovalMode,
|
||||
_approval_mode_override_settings,
|
||||
_approval_mode_settings,
|
||||
)
|
||||
from ._initialize_metadata import validate_initialize_metadata
|
||||
from ._inputs import (
|
||||
ImageInput as ImageInput,
|
||||
Input,
|
||||
@@ -25,9 +30,6 @@ from ._run import (
|
||||
from .async_client import AsyncAppServerClient
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
from .generated.v2_all import (
|
||||
ApprovalsReviewer,
|
||||
AskForApproval,
|
||||
AskForApprovalValue,
|
||||
ModelListResponse,
|
||||
Personality,
|
||||
ReasoningEffort,
|
||||
@@ -55,61 +57,7 @@ from .generated.v2_all import (
|
||||
TurnStartParams,
|
||||
TurnSteerResponse,
|
||||
)
|
||||
from .models import InitializeResponse, JsonObject, Notification, ServerInfo
|
||||
|
||||
|
||||
def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]:
|
||||
raw = user_agent.strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
if "/" in raw:
|
||||
name, version = raw.split("/", 1)
|
||||
return (name or None), (version or None)
|
||||
parts = raw.split(maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return raw, None
|
||||
|
||||
|
||||
class ApprovalMode(str, Enum):
|
||||
"""High-level approval behavior for escalated permission requests."""
|
||||
|
||||
deny_all = "deny_all"
|
||||
auto_review = "auto_review"
|
||||
|
||||
|
||||
def _approval_mode_settings(
|
||||
approval_mode: ApprovalMode,
|
||||
) -> tuple[AskForApproval, ApprovalsReviewer | None]:
|
||||
"""Map the public approval mode to generated app-server start params."""
|
||||
if not isinstance(approval_mode, ApprovalMode):
|
||||
supported = ", ".join(mode.value for mode in ApprovalMode)
|
||||
raise ValueError(f"approval_mode must be one of: {supported}")
|
||||
|
||||
match approval_mode:
|
||||
case ApprovalMode.auto_review:
|
||||
return (
|
||||
AskForApproval(root=AskForApprovalValue.on_request),
|
||||
ApprovalsReviewer.auto_review,
|
||||
)
|
||||
case ApprovalMode.deny_all:
|
||||
return AskForApproval(root=AskForApprovalValue.never), None
|
||||
case _:
|
||||
return _assert_never_approval_mode(approval_mode)
|
||||
|
||||
|
||||
def _assert_never_approval_mode(approval_mode: NoReturn) -> NoReturn:
|
||||
"""Make approval mode mapping exhaustive for static type checkers."""
|
||||
raise AssertionError(f"Unhandled approval mode: {approval_mode!r}")
|
||||
|
||||
|
||||
def _approval_mode_override_settings(
|
||||
approval_mode: ApprovalMode | None,
|
||||
) -> tuple[AskForApproval | None, ApprovalsReviewer | None]:
|
||||
"""Map an optional public approval mode to app-server override params."""
|
||||
if approval_mode is None:
|
||||
return None, None
|
||||
return _approval_mode_settings(approval_mode)
|
||||
from .models import InitializeResponse, JsonObject, Notification
|
||||
|
||||
|
||||
class Codex:
|
||||
@@ -119,7 +67,7 @@ class Codex:
|
||||
self._client = AppServerClient(config=config)
|
||||
try:
|
||||
self._client.start()
|
||||
self._init = self._validate_initialize(self._client.initialize())
|
||||
self._init = validate_initialize_metadata(self._client.initialize())
|
||||
except Exception:
|
||||
self._client.close()
|
||||
raise
|
||||
@@ -130,44 +78,6 @@ class Codex:
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
self.close()
|
||||
|
||||
@staticmethod
|
||||
def _validate_initialize(payload: InitializeResponse) -> InitializeResponse:
|
||||
user_agent = (payload.userAgent or "").strip()
|
||||
server = payload.serverInfo
|
||||
|
||||
server_name: str | None = None
|
||||
server_version: str | None = None
|
||||
|
||||
if server is not None:
|
||||
server_name = (server.name or "").strip() or None
|
||||
server_version = (server.version or "").strip() or None
|
||||
|
||||
if (server_name is None or server_version is None) and user_agent:
|
||||
parsed_name, parsed_version = _split_user_agent(user_agent)
|
||||
if server_name is None:
|
||||
server_name = parsed_name
|
||||
if server_version is None:
|
||||
server_version = parsed_version
|
||||
|
||||
normalized_server_name = (server_name or "").strip()
|
||||
normalized_server_version = (server_version or "").strip()
|
||||
if not user_agent or not normalized_server_name or not normalized_server_version:
|
||||
raise RuntimeError(
|
||||
"initialize response missing required metadata "
|
||||
f"(user_agent={user_agent!r}, server_name={normalized_server_name!r}, server_version={normalized_server_version!r})"
|
||||
)
|
||||
|
||||
if server is None:
|
||||
payload.serverInfo = ServerInfo(
|
||||
name=normalized_server_name,
|
||||
version=normalized_server_version,
|
||||
)
|
||||
else:
|
||||
server.name = normalized_server_name
|
||||
server.version = normalized_server_version
|
||||
|
||||
return payload
|
||||
|
||||
@property
|
||||
def metadata(self) -> InitializeResponse:
|
||||
return self._init
|
||||
@@ -354,7 +264,7 @@ class AsyncCodex:
|
||||
try:
|
||||
await self._client.start()
|
||||
payload = await self._client.initialize()
|
||||
self._init = Codex._validate_initialize(payload)
|
||||
self._init = validate_initialize_metadata(payload)
|
||||
self._initialized = True
|
||||
except Exception:
|
||||
await self._client.close()
|
||||
|
||||
@@ -39,6 +39,61 @@ def test_thread_set_name_and_read(tmp_path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_sync_and_async_initialization_round_trip_metadata(tmp_path) -> None:
|
||||
"""Public clients should initialize and start threads through app-server."""
|
||||
|
||||
async def async_scenario(harness: AppServerHarness) -> dict[str, object]:
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
server = codex.metadata.serverInfo
|
||||
return {
|
||||
"thread_id": thread.id,
|
||||
"user_agent": codex.metadata.userAgent,
|
||||
"server_name": None if server is None else server.name,
|
||||
"server_version": None if server is None else server.version,
|
||||
}
|
||||
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
server = codex.metadata.serverInfo
|
||||
sync_summary = {
|
||||
"thread_id": thread.id,
|
||||
"user_agent": codex.metadata.userAgent,
|
||||
"server_name": None if server is None else server.name,
|
||||
"server_version": None if server is None else server.version,
|
||||
}
|
||||
async_summary = asyncio.run(async_scenario(harness))
|
||||
|
||||
assert {
|
||||
"sync": {
|
||||
"thread_id_present": bool(sync_summary["thread_id"]),
|
||||
"user_agent_present": bool(sync_summary["user_agent"]),
|
||||
"server_name_present": bool(sync_summary["server_name"]),
|
||||
"server_version_present": bool(sync_summary["server_version"]),
|
||||
},
|
||||
"async": {
|
||||
"thread_id_present": bool(async_summary["thread_id"]),
|
||||
"user_agent_present": bool(async_summary["user_agent"]),
|
||||
"server_name_present": bool(async_summary["server_name"]),
|
||||
"server_version_present": bool(async_summary["server_version"]),
|
||||
},
|
||||
} == {
|
||||
"sync": {
|
||||
"thread_id_present": True,
|
||||
"user_agent_present": True,
|
||||
"server_name_present": True,
|
||||
"server_version_present": True,
|
||||
},
|
||||
"async": {
|
||||
"thread_id_present": True,
|
||||
"user_agent_present": True,
|
||||
"server_name_present": True,
|
||||
"server_version_present": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_thread_list_filters_archived_threads(tmp_path) -> None:
|
||||
"""Thread listing should reflect archive state through app-server."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
|
||||
@@ -18,6 +18,7 @@ from openai_codex import (
|
||||
RunResult,
|
||||
Thread,
|
||||
)
|
||||
from openai_codex._initialize_metadata import validate_initialize_metadata
|
||||
from openai_codex.types import InitializeResponse
|
||||
|
||||
EXPECTED_ROOT_EXPORTS = [
|
||||
@@ -444,7 +445,7 @@ def test_lifecycle_methods_are_codex_scoped() -> None:
|
||||
def test_initialize_metadata_parses_user_agent_shape() -> None:
|
||||
"""Initialize metadata should accept the legacy user-agent-only payload shape."""
|
||||
payload = InitializeResponse.model_validate({"userAgent": "codex-cli/1.2.3"})
|
||||
parsed = Codex._validate_initialize(payload)
|
||||
parsed = validate_initialize_metadata(payload)
|
||||
assert parsed is payload
|
||||
assert parsed.userAgent == "codex-cli/1.2.3"
|
||||
assert parsed.serverInfo is not None
|
||||
@@ -455,7 +456,7 @@ def test_initialize_metadata_parses_user_agent_shape() -> None:
|
||||
def test_initialize_metadata_requires_non_empty_information() -> None:
|
||||
"""Initialize metadata should fail when the runtime gives no identity signal."""
|
||||
try:
|
||||
Codex._validate_initialize(InitializeResponse.model_validate({}))
|
||||
validate_initialize_metadata(InitializeResponse.model_validate({}))
|
||||
except RuntimeError as exc:
|
||||
assert "missing required metadata" in str(exc)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user