Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ enum AgentDemoRuntimeFactory {
static func makeLive(
model: String = "gpt-5.4",
enableWebSearch: Bool = false,
reasoningEffort: ReasoningEffort = .medium,
stateURL: URL? = nil,
keychainAccount: String = "live"
) -> AgentDemoViewModel {
Expand All @@ -37,6 +38,7 @@ enum AgentDemoRuntimeFactory {
authenticationMethod: .deviceCode,
model: model,
enableWebSearch: enableWebSearch,
reasoningEffort: reasoningEffort,
stateURL: stateURL,
keychainAccount: keychainAccount,
approvalInbox: approvalInbox,
Expand All @@ -46,6 +48,7 @@ enum AgentDemoRuntimeFactory {
runtime: runtime,
model: model,
enableWebSearch: enableWebSearch,
reasoningEffort: reasoningEffort,
stateURL: stateURL,
keychainAccount: keychainAccount,
approvalInbox: approvalInbox,
Expand All @@ -61,6 +64,7 @@ enum AgentDemoRuntimeFactory {
authenticationMethod: DemoAuthenticationMethod,
model: String = "gpt-5.4",
enableWebSearch: Bool = false,
reasoningEffort: ReasoningEffort = .medium,
stateURL: URL? = nil,
keychainAccount: String = "live",
approvalInbox: ApprovalInbox,
Expand Down Expand Up @@ -90,6 +94,7 @@ enum AgentDemoRuntimeFactory {
backend: CodexResponsesBackend(
configuration: CodexResponsesBackendConfiguration(
model: model,
reasoningEffort: reasoningEffort,
enableWebSearch: enableWebSearch
)
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@ extension AgentDemoView {
ScrollView(.horizontal, showsIndicators: false) {
headerActions
}

VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("Model")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)

Text(viewModel.model)
.font(.caption)
.foregroundStyle(.secondary)
}

ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(ReasoningEffort.allCases, id: \.self) { effort in
reasoningEffortButton(for: effort)
}
}
}

Text("Thinking level for future requests.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}

Expand Down Expand Up @@ -132,6 +156,27 @@ extension AgentDemoView {
}
}

@ViewBuilder
func reasoningEffortButton(for effort: ReasoningEffort) -> some View {
if effort == viewModel.reasoningEffort {
Button(effort.demoTitle) {
Task {
await viewModel.updateReasoningEffort(effort)
}
}
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canReconfigureRuntime)
} else {
Button(effort.demoTitle) {
Task {
await viewModel.updateReasoningEffort(effort)
}
}
.buttonStyle(.bordered)
.disabled(!viewModel.canReconfigureRuntime)
}
}

@ViewBuilder
var personaExamples: some View {
if viewModel.session != nil {
Expand Down Expand Up @@ -377,3 +422,18 @@ extension AgentDemoView {
}
}
}

private extension ReasoningEffort {
var demoTitle: String {
switch self {
case .low:
"Think Low"
case .medium:
"Think Medium"
case .high:
"Think High"
case .extraHigh:
"Think Extra High"
}
}
}
62 changes: 62 additions & 0 deletions DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ final class AgentDemoViewModel: @unchecked Sendable {
var cachedAIReminderBody: String?
var cachedAIReminderKey: String?
var cachedAIReminderGeneratedAt: Date?
var reasoningEffort: ReasoningEffort
var currentAuthenticationMethod: DemoAuthenticationMethod = .deviceCode

let approvalInbox: ApprovalInbox
let deviceCodePromptCoordinator: DeviceCodePromptCoordinator
Expand All @@ -125,6 +127,7 @@ final class AgentDemoViewModel: @unchecked Sendable {
runtime: AgentRuntime,
model: String,
enableWebSearch: Bool,
reasoningEffort: ReasoningEffort,
stateURL: URL?,
keychainAccount: String,
approvalInbox: ApprovalInbox,
Expand All @@ -133,6 +136,7 @@ final class AgentDemoViewModel: @unchecked Sendable {
self.runtime = runtime
self.model = model
self.enableWebSearch = enableWebSearch
self.reasoningEffort = reasoningEffort
self.stateURL = stateURL
self.keychainAccount = keychainAccount
self.approvalInbox = approvalInbox
Expand Down Expand Up @@ -165,6 +169,17 @@ final class AgentDemoViewModel: @unchecked Sendable {
dailyStepGoal > 0 && todayStepCount >= dailyStepGoal
}

var canReconfigureRuntime: Bool {
!isAuthenticating && threads.allSatisfy { thread in
switch thread.status {
case .idle, .failed:
true
case .streaming, .waitingForApproval, .waitingForToolResult:
false
}
}
}

func restore() async {
do {
_ = try await runtime.restore()
Expand All @@ -183,10 +198,12 @@ final class AgentDemoViewModel: @unchecked Sendable {

isAuthenticating = true
lastError = nil
currentAuthenticationMethod = authenticationMethod
runtime = AgentDemoRuntimeFactory.makeRuntime(
authenticationMethod: authenticationMethod,
model: model,
enableWebSearch: enableWebSearch,
reasoningEffort: reasoningEffort,
stateURL: stateURL,
keychainAccount: keychainAccount,
approvalInbox: approvalInbox,
Expand All @@ -213,6 +230,51 @@ final class AgentDemoViewModel: @unchecked Sendable {
}
}

func updateReasoningEffort(_ reasoningEffort: ReasoningEffort) async {
guard self.reasoningEffort != reasoningEffort else {
return
}

guard canReconfigureRuntime else {
lastError = "Wait for the current turn to finish before switching thinking level."
return
}

self.reasoningEffort = reasoningEffort
let preservedActiveThreadID = activeThreadID
let preservedHealthCoachThreadID = healthCoachThreadID

runtime = AgentDemoRuntimeFactory.makeRuntime(
authenticationMethod: currentAuthenticationMethod,
model: model,
enableWebSearch: enableWebSearch,
reasoningEffort: reasoningEffort,
stateURL: stateURL,
keychainAccount: keychainAccount,
approvalInbox: approvalInbox,
deviceCodePromptCoordinator: deviceCodePromptCoordinator
)

do {
_ = try await runtime.restore()
await registerDemoTool()
await refreshSnapshot()

if let preservedActiveThreadID,
threads.contains(where: { $0.id == preservedActiveThreadID }) {
activeThreadID = preservedActiveThreadID
messages = await runtime.messages(for: preservedActiveThreadID)
}

if let preservedHealthCoachThreadID,
threads.contains(where: { $0.id == preservedHealthCoachThreadID }) {
healthCoachThreadID = preservedHealthCoachThreadID
}
} catch {
lastError = error.localizedDescription
}
}

func createThread() async {
await createThreadInternal(
title: nil,
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ let runtime = try AgentRuntime(configuration: .init(
backend: CodexResponsesBackend(
configuration: .init(
model: "gpt-5.4",
reasoningEffort: .medium,
enableWebSearch: true
)
),
Expand Down Expand Up @@ -73,7 +74,9 @@ let stream = try await runtime.sendMessage(
| Threaded runtime state + restore | Yes |
| Streamed assistant output | Yes |
| Host-defined tools + approval flow | Yes |
| Configurable thinking level | Yes |
| Web search toggle (`enableWebSearch`) | Yes |
| Built-in request retry/backoff | Yes (configurable) |
| Text + image input | Yes |
| Assistant image attachment rendering | Yes |
| Video/audio input attachments | Not yet |
Expand Down Expand Up @@ -115,6 +118,42 @@ The recommended production path for iOS is:

For browser OAuth, `CodexKit` uses the Codex-compatible redirect `http://localhost:1455/auth/callback` internally and only runs the loopback listener during active auth.

`CodexResponsesBackend` also includes built-in retry/backoff for transient failures (`429`, `5xx`, and network-transient URL errors like `networkConnectionLost`). You can tune or disable it:

```swift
let backend = CodexResponsesBackend(
configuration: .init(
model: "gpt-5.4",
requestRetryPolicy: .init(
maxAttempts: 3,
initialBackoff: 0.5,
maxBackoff: 4,
jitterFactor: 0.2
)
// or disable:
// requestRetryPolicy: .disabled
)
)
```

`CodexResponsesBackendConfiguration` also lets you control the model thinking level:

```swift
let backend = CodexResponsesBackend(
configuration: .init(
model: "gpt-5.4",
reasoningEffort: .high
)
)
```

Available values:

- `.low`
- `.medium`
- `.high`
- `.extraHigh`

## Image Attachments

`CodexKit` supports:
Expand Down Expand Up @@ -300,7 +339,7 @@ print(preview)
- Use persistent runtime state (`FileRuntimeStateStore`)
- Gate impactful tools with approvals
- Handle auth cancellation and sign-out resets cleanly
- Add retry/backoff around network-dependent UX
- Tune retry/backoff policy for your app’s UX and latency targets
- Log tool invocations and failures for supportability
- Validate HealthKit/notification permission fallback states if using health features

Expand Down
15 changes: 15 additions & 0 deletions Sources/CodexKit/Auth/ChatGPTSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ public actor ChatGPTSessionManager {
return session
}

public func recoverUnauthorizedSession(
previousAccessToken: String?
) async throws -> ChatGPTSession {
if let restored = try secureStore.loadSession() {
session = restored
if let previousAccessToken,
restored.accessToken != previousAccessToken,
!restored.requiresRefresh() {
return restored
}
}

return try await refresh(reason: .unauthorized)
}

private func requireStoredSession() throws -> ChatGPTSession {
guard let session else {
throw AgentRuntimeError.signedOut()
Expand Down
Loading
Loading