query = ParseQuery(url.Query);
+ query.TryGetValue("error", out string? error);
+ query.TryGetValue("code", out string? code);
+ query.TryGetValue("state", out string? state);
+
+ if (!string.IsNullOrEmpty(error))
+ {
+ await WriteHtmlAsync(context.Response, 400, "OpenAI authentication did not complete.", cancellationToken)
+ .ConfigureAwait(false);
+ throw new InvalidOperationException($"OpenAI OAuth failed: {error}");
+ }
+
+ if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
+ {
+ await WriteHtmlAsync(context.Response, 400, "Missing code or state parameter.", cancellationToken)
+ .ConfigureAwait(false);
+ throw new InvalidOperationException("Missing code or state parameter from OpenAI callback.");
+ }
+
+ if (!string.Equals(state, session.State, StringComparison.Ordinal))
+ {
+ await WriteHtmlAsync(context.Response, 400, "State mismatch.", cancellationToken).ConfigureAwait(false);
+ throw new InvalidOperationException("OAuth state mismatch.");
+ }
+
+ await WriteHtmlAsync(
+ context.Response,
+ 200,
+ "OpenAI authentication completed. You can close this window.",
+ cancellationToken).ConfigureAwait(false);
+
+ return new OpenAiCodexCallbackResult(code, state);
+ }
+
+ private static async ValueTask WriteHtmlAsync(
+ HttpListenerResponse response,
+ int statusCode,
+ string message,
+ CancellationToken cancellationToken)
+ {
+ byte[] body = Encoding.UTF8.GetBytes(
+ $"{WebUtility.HtmlEncode(message)}
");
+ response.StatusCode = statusCode;
+ response.ContentType = "text/html; charset=utf-8";
+ response.ContentLength64 = body.Length;
+ await response.OutputStream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
+ response.OutputStream.Close();
+ }
+
+ private static OAuthTokenSet ToTokenSet(
+ OpenAiCodexTokenResponse token,
+ bool requireRefreshToken = true,
+ string? fallbackAccountId = null)
+ {
+ if (!string.IsNullOrEmpty(token.Error))
+ {
+ throw new InvalidOperationException(
+ $"OpenAI token request failed: {token.Error}{(string.IsNullOrEmpty(token.ErrorDescription) ? "" : $": {token.ErrorDescription}")}");
+ }
+
+ if (string.IsNullOrEmpty(token.AccessToken) || token.ExpiresIn is null)
+ {
+ throw new InvalidOperationException("Invalid OpenAI Codex token response.");
+ }
+
+ if (requireRefreshToken && string.IsNullOrEmpty(token.RefreshToken))
+ {
+ throw new InvalidOperationException("Invalid OpenAI Codex token response (missing refresh_token).");
+ }
+
+ string? accountId = ExtractAccountId(token.AccessToken) ?? fallbackAccountId;
+ if (string.IsNullOrEmpty(accountId))
+ {
+ throw new InvalidOperationException(
+ "OpenAI access token did not contain a chatgpt_account_id claim. Re-run login.");
+ }
+
+ return new OAuthTokenSet(
+ AccessToken: token.AccessToken,
+ RefreshToken: token.RefreshToken,
+ ExpiresAt: DateTimeOffset.UtcNow.AddSeconds(token.ExpiresIn.Value),
+ Scopes: Scopes,
+ BaseUrl: DefaultBaseUrl,
+ AccountId: accountId);
+ }
+
+ private async ValueTask SendAsync(
+ HttpRequestMessage request,
+ JsonTypeInfo typeInfo,
+ CancellationToken cancellationToken)
+ {
+ using HttpResponseMessage response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException($"{(int)response.StatusCode} {response.ReasonPhrase}: {body}");
+ }
+
+ return JsonSerializer.Deserialize(body, typeInfo)
+ ?? throw new InvalidOperationException("Empty response from OpenAI.");
+ }
+
+ private static string CreateCodeVerifier()
+ {
+ Span bytes = stackalloc byte[32];
+ RandomNumberGenerator.Fill(bytes);
+ return Base64UrlEncode(bytes);
+ }
+
+ private static string CreateCodeChallenge(string verifier)
+ {
+ byte[] hash = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
+ return Base64UrlEncode(hash);
+ }
+
+ private static string CreateState()
+ {
+ Span bytes = stackalloc byte[16];
+ RandomNumberGenerator.Fill(bytes);
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ private static string Base64UrlEncode(ReadOnlySpan data) =>
+ Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private static Dictionary ParseQuery(string query)
+ {
+ Dictionary result = new(StringComparer.Ordinal);
+ if (string.IsNullOrEmpty(query))
+ {
+ return result;
+ }
+
+ string text = query.StartsWith('?') ? query[1..] : query;
+ foreach (string pair in text.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ int eq = pair.IndexOf('=');
+ if (eq < 0)
+ {
+ result[Uri.UnescapeDataString(pair.Replace('+', ' '))] = string.Empty;
+ continue;
+ }
+
+ string key = Uri.UnescapeDataString(pair[..eq].Replace('+', ' '));
+ string value = Uri.UnescapeDataString(pair[(eq + 1)..].Replace('+', ' '));
+ result[key] = value;
+ }
+
+ return result;
+ }
+}
+
+/// PKCE session material for one OpenAI Codex login attempt.
+public sealed record OpenAiCodexPkceSession(
+ string Verifier,
+ string Challenge,
+ string State,
+ string AuthorizeUrl);
+
+/// Authorization code + state returned from the callback or paste path.
+public sealed record OpenAiCodexCallbackResult(string Code, string State);
+
+/// Token response from OpenAI's OAuth token endpoint.
+public sealed record OpenAiCodexTokenResponse(
+ [property: JsonPropertyName("access_token")] string? AccessToken,
+ [property: JsonPropertyName("refresh_token")] string? RefreshToken,
+ [property: JsonPropertyName("expires_in")] int? ExpiresIn,
+ [property: JsonPropertyName("error")] string? Error,
+ [property: JsonPropertyName("error_description")] string? ErrorDescription);
+
+/// Source-generated JSON contracts for OpenAI Codex OAuth.
+[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+[JsonSerializable(typeof(OpenAiCodexTokenResponse))]
+public sealed partial class OpenAiCodexJsonContext : JsonSerializerContext;
diff --git a/src/WinHarness.Providers/OpenAiCodexResponsesChatClient.cs b/src/WinHarness.Providers/OpenAiCodexResponsesChatClient.cs
new file mode 100644
index 0000000..babc07f
--- /dev/null
+++ b/src/WinHarness.Providers/OpenAiCodexResponsesChatClient.cs
@@ -0,0 +1,864 @@
+using System.Net.Http.Headers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace WinHarness.Providers;
+
+///
+/// Hand-rolled OpenAI Codex Responses API client implementing .
+/// Streaming, tool calls, usage, and reasoning only — no SDK, AOT-safe (ADR-0005 / PR-B4).
+///
+internal sealed class OpenAiCodexResponsesChatClient : IChatClient
+{
+ private static readonly ChatClientMetadata Metadata = new("openai-codex");
+
+ private readonly HttpClient _http;
+ private readonly Uri _endpoint;
+ private readonly string _modelId;
+ private readonly IAuthTokenSource _tokenSource;
+ private readonly string? _accountId;
+ private readonly bool _ownsHttp;
+
+ public OpenAiCodexResponsesChatClient(
+ HttpClient http,
+ Uri endpoint,
+ string modelId,
+ IAuthTokenSource tokenSource,
+ string? accountId,
+ bool ownsHttp = false)
+ {
+ _http = http;
+ _endpoint = endpoint;
+ _modelId = modelId;
+ _tokenSource = tokenSource;
+ _accountId = accountId;
+ _ownsHttp = ownsHttp;
+ }
+
+ public void Dispose()
+ {
+ if (_ownsHttp)
+ {
+ _http.Dispose();
+ }
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ if (serviceKey is not null)
+ {
+ return null;
+ }
+
+ if (serviceType == typeof(ChatClientMetadata))
+ {
+ return Metadata;
+ }
+
+ if (serviceType.IsInstanceOfType(this))
+ {
+ return this;
+ }
+
+ return null;
+ }
+
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ List updates = [];
+ await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ updates.Add(update);
+ }
+
+ return updates.ToChatResponse();
+ }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ string token = await _tokenSource.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
+ string? accountId = _accountId;
+ if (string.IsNullOrEmpty(accountId) && _tokenSource is OAuthTokenSource oauthSource)
+ {
+ OAuthTokenSet set = await oauthSource.LoadTokenSetAsync(cancellationToken).ConfigureAwait(false);
+ accountId = set.AccountId;
+ }
+
+ if (string.IsNullOrEmpty(accountId))
+ {
+ throw new InvalidOperationException(
+ "OpenAI Codex requires a chatgpt_account_id. Run 'winharness login --provider openai'.");
+ }
+
+ byte[] body = BuildRequestBody(messages, options, stream: true);
+
+ using HttpRequestMessage request = new(HttpMethod.Post, _endpoint);
+ request.Content = new ByteArrayContent(body);
+ request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
+ ApplyAuthHeaders(request, token, accountId);
+
+ using HttpResponseMessage response = await _http
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ string errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ throw new InvalidOperationException(FormatHttpError(response, errorBody));
+ }
+
+ await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ using StreamReader reader = new(stream, Encoding.UTF8);
+
+ Dictionary toolItems = [];
+ long? inputTokens = null;
+ long? outputTokens = null;
+ string? responseId = null;
+ string? modelId = _modelId;
+
+ while (true)
+ {
+ string? line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ if (line is null)
+ {
+ break;
+ }
+
+ if (line.Length == 0 || line.StartsWith(":", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!line.StartsWith("data:", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ string data = line["data:".Length..].Trim();
+ if (data.Length == 0 || data == "[DONE]")
+ {
+ continue;
+ }
+
+ using JsonDocument document = JsonDocument.Parse(data);
+ JsonElement root = document.RootElement;
+ string? type = root.TryGetProperty("type", out JsonElement typeElement)
+ ? typeElement.GetString()
+ : null;
+
+ switch (type)
+ {
+ case "response.created":
+ case "response.in_progress":
+ if (root.TryGetProperty("response", out JsonElement created) &&
+ created.TryGetProperty("id", out JsonElement createdId))
+ {
+ responseId = createdId.GetString() ?? responseId;
+ }
+
+ break;
+
+ case "response.output_text.delta":
+ {
+ string? delta = root.TryGetProperty("delta", out JsonElement deltaElement)
+ ? deltaElement.GetString()
+ : null;
+ if (!string.IsNullOrEmpty(delta))
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, delta)
+ {
+ ModelId = modelId,
+ ResponseId = responseId,
+ };
+ }
+
+ break;
+ }
+
+ case "response.output_item.added":
+ HandleOutputItemAdded(root, toolItems);
+ break;
+
+ case "response.function_call_arguments.delta":
+ HandleFunctionCallArgumentsDelta(root, toolItems);
+ break;
+
+ case "response.function_call_arguments.done":
+ HandleFunctionCallArgumentsDone(root, toolItems);
+ break;
+
+ case "response.output_item.done":
+ foreach (ChatResponseUpdate update in HandleOutputItemDone(root, toolItems, responseId, modelId))
+ {
+ yield return update;
+ }
+
+ break;
+
+ case "response.completed":
+ case "response.incomplete":
+ if (root.TryGetProperty("response", out JsonElement completed))
+ {
+ if (completed.TryGetProperty("id", out JsonElement completedId))
+ {
+ responseId = completedId.GetString() ?? responseId;
+ }
+
+ if (completed.TryGetProperty("model", out JsonElement modelElement))
+ {
+ modelId = modelElement.GetString() ?? modelId;
+ }
+
+ if (completed.TryGetProperty("usage", out JsonElement usage))
+ {
+ if (usage.TryGetProperty("input_tokens", out JsonElement input))
+ {
+ inputTokens = input.GetInt64();
+ }
+
+ if (usage.TryGetProperty("output_tokens", out JsonElement output))
+ {
+ outputTokens = output.GetInt64();
+ }
+ }
+ }
+
+ break;
+
+ case "response.failed":
+ case "error":
+ {
+ string errorMessage = "OpenAI Codex stream error.";
+ if (root.TryGetProperty("response", out JsonElement failed) &&
+ failed.TryGetProperty("error", out JsonElement error) &&
+ error.TryGetProperty("message", out JsonElement message))
+ {
+ errorMessage = message.GetString() ?? errorMessage;
+ }
+ else if (root.TryGetProperty("error", out JsonElement topError) &&
+ topError.TryGetProperty("message", out JsonElement topMessage))
+ {
+ errorMessage = topMessage.GetString() ?? errorMessage;
+ }
+
+ throw new InvalidOperationException(errorMessage);
+ }
+ }
+ }
+
+ // Emit any tool calls that never got an output_item.done event.
+ foreach ((int _, FunctionCallAccumulator accumulator) in toolItems)
+ {
+ if (accumulator.Emitted || string.IsNullOrEmpty(accumulator.Name))
+ {
+ continue;
+ }
+
+ yield return CreateFunctionCallUpdate(accumulator, responseId, modelId);
+ }
+
+ if (inputTokens is not null || outputTokens is not null)
+ {
+ UsageDetails details = new()
+ {
+ InputTokenCount = inputTokens,
+ OutputTokenCount = outputTokens,
+ TotalTokenCount = (inputTokens ?? 0) + (outputTokens ?? 0)
+ };
+ yield return new ChatResponseUpdate(ChatRole.Assistant, [new UsageContent(details)])
+ {
+ ModelId = modelId,
+ ResponseId = responseId,
+ };
+ }
+ }
+
+ private void ApplyAuthHeaders(HttpRequestMessage request, string token, string accountId)
+ {
+ foreach ((string name, string value) in OpenAiCodexOAuthFlow.RequestHeaders)
+ {
+ request.Headers.TryAddWithoutValidation(name, value);
+ }
+
+ request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
+ request.Headers.TryAddWithoutValidation("chatgpt-account-id", accountId);
+ request.Headers.TryAddWithoutValidation(
+ "User-Agent",
+ $"winharness ({Environment.OSVersion.Platform}; {RuntimeInformation.OSArchitecture})");
+ }
+
+ private byte[] BuildRequestBody(IEnumerable messages, ChatOptions? options, bool stream)
+ {
+ using MemoryStream streamBuffer = new();
+ using (Utf8JsonWriter writer = new(streamBuffer))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("model", options?.ModelId ?? _modelId);
+ writer.WriteBoolean("store", false);
+ writer.WriteBoolean("stream", stream);
+ writer.WriteBoolean("parallel_tool_calls", true);
+ writer.WriteString("tool_choice", "auto");
+
+ writer.WritePropertyName("text");
+ writer.WriteStartObject();
+ writer.WriteString("verbosity", "low");
+ writer.WriteEndObject();
+
+ // Do not request reasoning.encrypted_content until we round-trip
+ // those items on subsequent turns (requesting without replay breaks
+ // multi-turn Codex reasoning sessions).
+
+ List systemParts = [];
+ List conversation = [];
+ foreach (ChatMessage message in messages)
+ {
+ if (message.Role == ChatRole.System)
+ {
+ if (!string.IsNullOrEmpty(message.Text))
+ {
+ systemParts.Add(message.Text);
+ }
+
+ continue;
+ }
+
+ conversation.Add(message);
+ }
+
+ string instructions = systemParts.Count > 0
+ ? string.Join("\n\n", systemParts)
+ : "You are a helpful assistant.";
+ writer.WriteString("instructions", instructions);
+
+ writer.WritePropertyName("input");
+ writer.WriteStartArray();
+ int messageIndex = 0;
+ foreach (ChatMessage message in conversation)
+ {
+ WriteInputItems(writer, message, messageIndex++);
+ }
+
+ writer.WriteEndArray();
+
+ if (options?.Tools is { Count: > 0 } tools)
+ {
+ writer.WritePropertyName("tools");
+ writer.WriteStartArray();
+ foreach (AITool tool in tools)
+ {
+ if (tool is not AIFunction function)
+ {
+ continue;
+ }
+
+ writer.WriteStartObject();
+ writer.WriteString("type", "function");
+ writer.WriteString("name", function.Name);
+ if (!string.IsNullOrEmpty(function.Description))
+ {
+ writer.WriteString("description", function.Description);
+ }
+
+ writer.WritePropertyName("parameters");
+ function.JsonSchema.WriteTo(writer);
+ writer.WriteBoolean("strict", false);
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndArray();
+ }
+
+ if (options?.Temperature is { } temperature)
+ {
+ writer.WriteNumber("temperature", temperature);
+ }
+
+ if (options?.Reasoning is { Effort: { } effort } && effort != ReasoningEffort.None)
+ {
+ string effortName = effort switch
+ {
+ ReasoningEffort.Low => "low",
+ ReasoningEffort.Medium => "medium",
+ ReasoningEffort.High => "high",
+ ReasoningEffort.ExtraHigh => "xhigh",
+ _ => "medium"
+ };
+
+ writer.WritePropertyName("reasoning");
+ writer.WriteStartObject();
+ writer.WriteString("effort", effortName);
+ writer.WriteString("summary", "auto");
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndObject();
+ }
+
+ return streamBuffer.ToArray();
+ }
+
+ private static void WriteInputItems(Utf8JsonWriter writer, ChatMessage message, int messageIndex)
+ {
+ if (message.Role == ChatRole.User)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("role", "user");
+ writer.WritePropertyName("content");
+ writer.WriteStartArray();
+ writer.WriteStartObject();
+ writer.WriteString("type", "input_text");
+ writer.WriteString("text", message.Text ?? string.Empty);
+ writer.WriteEndObject();
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ return;
+ }
+
+ if (message.Role == ChatRole.Assistant)
+ {
+ bool wroteText = false;
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is TextContent text && !string.IsNullOrEmpty(text.Text))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("type", "message");
+ writer.WriteString("role", "assistant");
+ writer.WriteString("status", "completed");
+ writer.WriteString("id", $"msg_wh_{messageIndex}");
+ writer.WritePropertyName("content");
+ writer.WriteStartArray();
+ writer.WriteStartObject();
+ writer.WriteString("type", "output_text");
+ writer.WriteString("text", text.Text);
+ writer.WritePropertyName("annotations");
+ writer.WriteStartArray();
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ wroteText = true;
+ }
+ else if (content is FunctionCallContent call)
+ {
+ (string callId, string? itemId) = SplitFunctionCallId(call.CallId);
+ writer.WriteStartObject();
+ writer.WriteString("type", "function_call");
+ if (!string.IsNullOrEmpty(itemId))
+ {
+ writer.WriteString("id", itemId);
+ }
+
+ writer.WriteString("call_id", callId);
+ writer.WriteString("name", call.Name);
+ writer.WriteString("arguments", SerializeArguments(call.Arguments));
+ writer.WriteEndObject();
+ }
+ }
+
+ if (!wroteText && message.Contents.Count == 0 && !string.IsNullOrEmpty(message.Text))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("type", "message");
+ writer.WriteString("role", "assistant");
+ writer.WriteString("status", "completed");
+ writer.WriteString("id", $"msg_wh_{messageIndex}");
+ writer.WritePropertyName("content");
+ writer.WriteStartArray();
+ writer.WriteStartObject();
+ writer.WriteString("type", "output_text");
+ writer.WriteString("text", message.Text);
+ writer.WritePropertyName("annotations");
+ writer.WriteStartArray();
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ }
+
+ return;
+ }
+
+ if (message.Role == ChatRole.Tool)
+ {
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is not FunctionResultContent result)
+ {
+ continue;
+ }
+
+ (string callId, _) = SplitFunctionCallId(result.CallId);
+ writer.WriteStartObject();
+ writer.WriteString("type", "function_call_output");
+ writer.WriteString("call_id", callId);
+ writer.WriteString("output", FormatToolResult(result));
+ writer.WriteEndObject();
+ }
+ }
+ }
+
+ private static void HandleOutputItemAdded(JsonElement root, Dictionary toolItems)
+ {
+ if (!root.TryGetProperty("output_index", out JsonElement indexElement) ||
+ !root.TryGetProperty("item", out JsonElement item))
+ {
+ return;
+ }
+
+ int index = indexElement.GetInt32();
+ string? itemType = item.TryGetProperty("type", out JsonElement typeElement) ? typeElement.GetString() : null;
+ if (itemType != "function_call")
+ {
+ return;
+ }
+
+ string callId = item.TryGetProperty("call_id", out JsonElement callIdElement)
+ ? callIdElement.GetString() ?? $"call_{index}"
+ : $"call_{index}";
+ string? itemId = item.TryGetProperty("id", out JsonElement idElement) ? idElement.GetString() : null;
+ string name = item.TryGetProperty("name", out JsonElement nameElement)
+ ? nameElement.GetString() ?? string.Empty
+ : string.Empty;
+ string args = item.TryGetProperty("arguments", out JsonElement argsElement)
+ ? argsElement.GetString() ?? string.Empty
+ : string.Empty;
+
+ toolItems[index] = new FunctionCallAccumulator(callId, itemId, name, args);
+ }
+
+ private static void HandleFunctionCallArgumentsDelta(
+ JsonElement root,
+ Dictionary toolItems)
+ {
+ if (!root.TryGetProperty("output_index", out JsonElement indexElement))
+ {
+ return;
+ }
+
+ int index = indexElement.GetInt32();
+ if (!toolItems.TryGetValue(index, out FunctionCallAccumulator? accumulator))
+ {
+ return;
+ }
+
+ string? delta = root.TryGetProperty("delta", out JsonElement deltaElement)
+ ? deltaElement.GetString()
+ : null;
+ if (!string.IsNullOrEmpty(delta))
+ {
+ accumulator.ArgumentsJson.Append(delta);
+ }
+ }
+
+ private static void HandleFunctionCallArgumentsDone(
+ JsonElement root,
+ Dictionary toolItems)
+ {
+ if (!root.TryGetProperty("output_index", out JsonElement indexElement))
+ {
+ return;
+ }
+
+ int index = indexElement.GetInt32();
+ if (!toolItems.TryGetValue(index, out FunctionCallAccumulator? accumulator))
+ {
+ return;
+ }
+
+ if (root.TryGetProperty("arguments", out JsonElement argsElement) &&
+ argsElement.GetString() is { Length: > 0 } full)
+ {
+ accumulator.ArgumentsJson.Clear();
+ accumulator.ArgumentsJson.Append(full);
+ }
+ }
+
+ private static IEnumerable HandleOutputItemDone(
+ JsonElement root,
+ Dictionary toolItems,
+ string? responseId,
+ string? modelId)
+ {
+ if (!root.TryGetProperty("output_index", out JsonElement indexElement) ||
+ !root.TryGetProperty("item", out JsonElement item))
+ {
+ yield break;
+ }
+
+ int index = indexElement.GetInt32();
+ string? itemType = item.TryGetProperty("type", out JsonElement typeElement) ? typeElement.GetString() : null;
+ if (itemType != "function_call")
+ {
+ yield break;
+ }
+
+ if (!toolItems.TryGetValue(index, out FunctionCallAccumulator? accumulator))
+ {
+ string callId = item.TryGetProperty("call_id", out JsonElement callIdElement)
+ ? callIdElement.GetString() ?? $"call_{index}"
+ : $"call_{index}";
+ string? itemId = item.TryGetProperty("id", out JsonElement idElement) ? idElement.GetString() : null;
+ string name = item.TryGetProperty("name", out JsonElement nameElement)
+ ? nameElement.GetString() ?? string.Empty
+ : string.Empty;
+ string args = item.TryGetProperty("arguments", out JsonElement argsElement)
+ ? argsElement.GetString() ?? string.Empty
+ : string.Empty;
+ accumulator = new FunctionCallAccumulator(callId, itemId, name, args);
+ toolItems[index] = accumulator;
+ }
+ else
+ {
+ if (item.TryGetProperty("arguments", out JsonElement argsElement) &&
+ argsElement.GetString() is { Length: > 0 } full)
+ {
+ accumulator.ArgumentsJson.Clear();
+ accumulator.ArgumentsJson.Append(full);
+ }
+
+ if (item.TryGetProperty("name", out JsonElement nameElement) &&
+ nameElement.GetString() is { Length: > 0 } name)
+ {
+ accumulator.Name = name;
+ }
+
+ if (item.TryGetProperty("id", out JsonElement idElement))
+ {
+ accumulator.ItemId = idElement.GetString();
+ }
+ }
+
+ if (accumulator.Emitted)
+ {
+ yield break;
+ }
+
+ yield return CreateFunctionCallUpdate(accumulator, responseId, modelId);
+ accumulator.Emitted = true;
+ }
+
+ private static ChatResponseUpdate CreateFunctionCallUpdate(
+ FunctionCallAccumulator accumulator,
+ string? responseId,
+ string? modelId)
+ {
+ string callId = string.IsNullOrEmpty(accumulator.ItemId)
+ ? accumulator.CallId
+ : $"{accumulator.CallId}|{accumulator.ItemId}";
+
+ IDictionary? arguments = ParseArguments(accumulator.ArgumentsJson.ToString());
+ FunctionCallContent content = new(callId, accumulator.Name, arguments);
+ return new ChatResponseUpdate(ChatRole.Assistant, [content])
+ {
+ ModelId = modelId,
+ ResponseId = responseId,
+ };
+ }
+
+ private static (string CallId, string? ItemId) SplitFunctionCallId(string? raw)
+ {
+ if (string.IsNullOrEmpty(raw))
+ {
+ return ("call_unknown", null);
+ }
+
+ int bar = raw.IndexOf('|');
+ if (bar < 0)
+ {
+ return (raw, null);
+ }
+
+ return (raw[..bar], raw[(bar + 1)..]);
+ }
+
+ private static string SerializeArguments(IDictionary? arguments)
+ {
+ if (arguments is null || arguments.Count == 0)
+ {
+ return "{}";
+ }
+
+ using MemoryStream buffer = new();
+ using (Utf8JsonWriter writer = new(buffer))
+ {
+ WriteArgumentsObject(writer, arguments);
+ }
+
+ return Encoding.UTF8.GetString(buffer.ToArray());
+ }
+
+ private static void WriteArgumentsObject(Utf8JsonWriter writer, IDictionary? arguments)
+ {
+ writer.WriteStartObject();
+ if (arguments is not null)
+ {
+ foreach ((string key, object? value) in arguments)
+ {
+ writer.WritePropertyName(key);
+ WriteJsonValue(writer, value);
+ }
+ }
+
+ writer.WriteEndObject();
+ }
+
+ private static void WriteJsonValue(Utf8JsonWriter writer, object? value)
+ {
+ switch (value)
+ {
+ case null:
+ writer.WriteNullValue();
+ break;
+ case string text:
+ writer.WriteStringValue(text);
+ break;
+ case bool boolean:
+ writer.WriteBooleanValue(boolean);
+ break;
+ case byte number:
+ writer.WriteNumberValue(number);
+ break;
+ case short number:
+ writer.WriteNumberValue(number);
+ break;
+ case int number:
+ writer.WriteNumberValue(number);
+ break;
+ case long number:
+ writer.WriteNumberValue(number);
+ break;
+ case float number:
+ writer.WriteNumberValue(number);
+ break;
+ case double number:
+ writer.WriteNumberValue(number);
+ break;
+ case decimal number:
+ writer.WriteNumberValue(number);
+ break;
+ case JsonElement element:
+ element.WriteTo(writer);
+ break;
+ case IDictionary dict:
+ WriteArgumentsObject(writer, dict);
+ break;
+ case System.Collections.IEnumerable enumerable when value is not string:
+ writer.WriteStartArray();
+ foreach (object? item in enumerable)
+ {
+ WriteJsonValue(writer, item);
+ }
+
+ writer.WriteEndArray();
+ break;
+ default:
+ writer.WriteStringValue(value.ToString());
+ break;
+ }
+ }
+
+ private static IDictionary? ParseArguments(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return new Dictionary();
+ }
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(json);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ return new Dictionary { ["raw"] = json };
+ }
+
+ Dictionary result = new(StringComparer.Ordinal);
+ foreach (JsonProperty property in document.RootElement.EnumerateObject())
+ {
+ result[property.Name] = ConvertJsonElement(property.Value);
+ }
+
+ return result;
+ }
+ catch (JsonException)
+ {
+ return new Dictionary { ["raw"] = json };
+ }
+ }
+
+ private static object? ConvertJsonElement(JsonElement element) =>
+ element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString(),
+ JsonValueKind.Number when element.TryGetInt64(out long l) => l,
+ JsonValueKind.Number when element.TryGetDouble(out double d) => d,
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.Null => null,
+ JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToList(),
+ JsonValueKind.Object => element.EnumerateObject()
+ .ToDictionary(static p => p.Name, static p => ConvertJsonElement(p.Value), StringComparer.Ordinal),
+ _ => element.GetRawText()
+ };
+
+ private static string FormatToolResult(FunctionResultContent result)
+ {
+ if (result.Exception is not null)
+ {
+ return result.Exception.Message;
+ }
+
+ return result.Result switch
+ {
+ null => string.Empty,
+ string text => text,
+ JsonElement element => element.GetRawText(),
+ _ => result.Result.ToString() ?? string.Empty
+ };
+ }
+
+ private static string FormatHttpError(HttpResponseMessage response, string body)
+ {
+ int status = (int)response.StatusCode;
+ string trimmed = body.Length > 500 ? body[..500] + "…" : body;
+ return status switch
+ {
+ 401 => $"OpenAI Codex authentication failed (401). Re-run 'winharness login --provider openai' or check tokens. {trimmed}",
+ 403 => $"OpenAI Codex rejected the request (403). Subscription may be expired or the grant revoked. {trimmed}",
+ 429 => $"OpenAI Codex rate limit (429). Wait and retry. {trimmed}",
+ _ => $"OpenAI Codex request failed ({status} {response.ReasonPhrase}). {trimmed}"
+ };
+ }
+
+ private sealed class FunctionCallAccumulator
+ {
+ public FunctionCallAccumulator(string callId, string? itemId, string name, string arguments)
+ {
+ CallId = callId;
+ ItemId = itemId;
+ Name = name;
+ ArgumentsJson = new StringBuilder(arguments);
+ }
+
+ public string CallId { get; }
+
+ public string? ItemId { get; set; }
+
+ public string Name { get; set; }
+
+ public StringBuilder ArgumentsJson { get; }
+
+ public bool Emitted { get; set; }
+ }
+}
diff --git a/src/WinHarness.Providers/OpenAiCompatibleProviderFactory.cs b/src/WinHarness.Providers/OpenAiCompatibleProviderFactory.cs
index a9165a0..de9de4b 100644
--- a/src/WinHarness.Providers/OpenAiCompatibleProviderFactory.cs
+++ b/src/WinHarness.Providers/OpenAiCompatibleProviderFactory.cs
@@ -54,6 +54,11 @@ public IChatProvider Create(string providerId, string modelId)
return new AnthropicMessagesChatProvider(provider, model, tokenSource);
}
+ if (string.Equals(provider.Kind, "openai-codex-responses", StringComparison.OrdinalIgnoreCase))
+ {
+ return new OpenAiCodexResponsesChatProvider(provider, model, tokenSource);
+ }
+
return new OpenAiCompatibleChatProvider(provider, model, tokenSource);
}
@@ -179,3 +184,71 @@ public IChatClient CreateChatClient()
ownsHttp: true);
}
}
+
+internal sealed class OpenAiCodexResponsesChatProvider : IChatProvider
+{
+ private readonly ProviderOptions _provider;
+ private readonly ModelOptions _model;
+ private readonly IAuthTokenSource _tokenSource;
+
+ public OpenAiCodexResponsesChatProvider(
+ ProviderOptions provider,
+ ModelOptions model,
+ IAuthTokenSource tokenSource)
+ {
+ _provider = provider;
+ _model = model;
+ _tokenSource = tokenSource;
+ }
+
+ public string ProviderId => _provider.Id;
+
+ public string ModelId => _model.Id;
+
+ public ProviderCapabilities Capabilities => _model.Capabilities;
+
+ public IChatClient CreateChatClient()
+ {
+ string baseUrl = _provider.BaseUrl ?? OpenAiCodexOAuthFlow.DefaultBaseUrl;
+ string endpointText = ResolveCodexResponsesUrl(baseUrl);
+ if (!Uri.TryCreate(endpointText, UriKind.Absolute, out Uri? endpoint))
+ {
+ throw new InvalidOperationException($"Provider '{_provider.Id}' baseUrl is not a valid absolute URI.");
+ }
+
+ string? accountId = null;
+ if (_tokenSource is OAuthTokenSource oauthSource)
+ {
+ // Eager load so missing login surfaces at client creation with a clear message.
+ OAuthTokenSet tokens = oauthSource.LoadTokenSetAsync(CancellationToken.None)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult();
+ accountId = tokens.AccountId;
+ }
+
+ return new OpenAiCodexResponsesChatClient(
+ new HttpClient(),
+ endpoint,
+ _model.ProviderModelId,
+ _tokenSource,
+ accountId,
+ ownsHttp: true);
+ }
+
+ private static string ResolveCodexResponsesUrl(string baseUrl)
+ {
+ string normalized = baseUrl.TrimEnd('/');
+ if (normalized.EndsWith("/codex/responses", StringComparison.OrdinalIgnoreCase))
+ {
+ return normalized;
+ }
+
+ if (normalized.EndsWith("/codex", StringComparison.OrdinalIgnoreCase))
+ {
+ return normalized + "/responses";
+ }
+
+ return normalized + "/codex/responses";
+ }
+}
diff --git a/src/WinHarness.Providers/ProviderServiceCollectionExtensions.cs b/src/WinHarness.Providers/ProviderServiceCollectionExtensions.cs
index 73cc47a..8a8a876 100644
--- a/src/WinHarness.Providers/ProviderServiceCollectionExtensions.cs
+++ b/src/WinHarness.Providers/ProviderServiceCollectionExtensions.cs
@@ -15,6 +15,7 @@ public static IServiceCollection AddWinHarnessProviders(this IServiceCollection
services.AddSingleton();
services.AddSingleton(static _ => new GitHubCopilotOAuthFlow(new HttpClient()));
services.AddSingleton(static _ => new AnthropicOAuthFlow(new HttpClient()));
+ services.AddSingleton(static _ => new OpenAiCodexOAuthFlow(new HttpClient()));
services.AddSingleton(static provider => new OpenAiCompatibleProviderFactory(
provider.GetRequiredService(),
provider.GetRequiredService(),
diff --git a/tests/WinHarness.IntegrationTests/OpenAiCodexResponsesProviderTests.cs b/tests/WinHarness.IntegrationTests/OpenAiCodexResponsesProviderTests.cs
new file mode 100644
index 0000000..83c0cbf
--- /dev/null
+++ b/tests/WinHarness.IntegrationTests/OpenAiCodexResponsesProviderTests.cs
@@ -0,0 +1,349 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using WinHarness.Configuration;
+using WinHarness.Platform;
+using WinHarness.Providers;
+using WinHarness.Serialization;
+
+namespace WinHarness.IntegrationTests;
+
+[TestClass]
+public sealed class OpenAiCodexResponsesProviderTests
+{
+ [TestMethod]
+ public async Task StreamsTextAndUsageFromResponsesSse()
+ {
+ await using FakeCodexServer server = await FakeCodexServer.StartAsync(CancellationToken.None);
+ WinHarnessOptions options = CreateOptions(server.Endpoint.ToString());
+ FakeCredentialStore store = new(CreateTokenSet());
+ OpenAiCompatibleProviderFactory factory = new(options, store, [new OpenAiCodexOAuthFlow(new HttpClient())]);
+ IChatProvider provider = factory.Create("openai-codex", "gpt-5.4");
+ using IChatClient client = provider.CreateChatClient();
+
+ StringBuilder streamed = new();
+ UsageDetails? usage = null;
+ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(
+ "Say hello.",
+ cancellationToken: CancellationToken.None))
+ {
+ streamed.Append(update.Text);
+ usage = update.Contents.OfType().LastOrDefault()?.Details ?? usage;
+ }
+
+ StringAssert.Contains(streamed.ToString(), "Hello from Codex");
+ Assert.IsNotNull(usage);
+ Assert.AreEqual(20L, usage!.InputTokenCount);
+ Assert.AreEqual(7L, usage.OutputTokenCount);
+ StringAssert.Contains(server.LastRequestBody!, "\"model\":\"gpt-5.4\"");
+ StringAssert.Contains(server.LastRequestBody!, "\"stream\":true");
+ StringAssert.Contains(server.LastRequestBody!, "\"instructions\"");
+ Assert.IsTrue(server.LastHeaders.ContainsKey("Authorization"));
+ Assert.AreEqual("acct-test", server.LastHeaders["chatgpt-account-id"]);
+ Assert.AreEqual("winharness", server.LastHeaders["originator"]);
+ Assert.AreEqual("responses=experimental", server.LastHeaders["OpenAI-Beta"]);
+ }
+
+ [TestMethod]
+ public async Task StreamsFunctionCallsAsToolUse()
+ {
+ await using FakeCodexServer server = await FakeCodexServer.StartToolUseAsync(CancellationToken.None);
+ WinHarnessOptions options = CreateOptions(server.Endpoint.ToString());
+ FakeCredentialStore store = new(CreateTokenSet());
+ OpenAiCompatibleProviderFactory factory = new(options, store, [new OpenAiCodexOAuthFlow(new HttpClient())]);
+ using IChatClient client = factory.Create("openai-codex", "gpt-5.4").CreateChatClient();
+
+ List calls = [];
+ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(
+ "Use a tool.",
+ cancellationToken: CancellationToken.None))
+ {
+ calls.AddRange(update.Contents.OfType());
+ }
+
+ Assert.AreEqual(1, calls.Count);
+ Assert.AreEqual("get_weather", calls[0].Name);
+ Assert.AreEqual("call_1|fc_1", calls[0].CallId);
+ Assert.IsNotNull(calls[0].Arguments);
+ Assert.AreEqual("SF", calls[0].Arguments!["location"]?.ToString());
+ }
+
+ [TestMethod]
+ public async Task SendsFunctionCallOutputOnToolFollowUp()
+ {
+ await using FakeCodexServer server = await FakeCodexServer.StartAsync(CancellationToken.None);
+ WinHarnessOptions options = CreateOptions(server.Endpoint.ToString());
+ FakeCredentialStore store = new(CreateTokenSet());
+ using IChatClient client = new OpenAiCompatibleProviderFactory(options, store, [new OpenAiCodexOAuthFlow(new HttpClient())])
+ .Create("openai-codex", "gpt-5.4")
+ .CreateChatClient();
+
+ List history =
+ [
+ new(ChatRole.User, "What's the weather?"),
+ new(ChatRole.Assistant,
+ [
+ new FunctionCallContent("call_1|fc_1", "get_weather", new Dictionary { ["location"] = "SF" }),
+ ]),
+ new(ChatRole.Tool, [new FunctionResultContent("call_1|fc_1", "sunny")]),
+ ];
+
+ await foreach (ChatResponseUpdate _ in client.GetStreamingResponseAsync(history, cancellationToken: CancellationToken.None))
+ {
+ }
+
+ Assert.IsNotNull(server.LastRequestBody);
+ StringAssert.Contains(server.LastRequestBody!, "\"type\":\"function_call\"");
+ StringAssert.Contains(server.LastRequestBody!, "\"type\":\"function_call_output\"");
+ StringAssert.Contains(server.LastRequestBody!, "\"call_id\":\"call_1\"");
+ StringAssert.Contains(server.LastRequestBody!, "sunny");
+ }
+
+ [TestMethod]
+ public void ValidatorAcceptsOpenAiCodexResponsesKind()
+ {
+ WinHarnessOptions options = CreateOptions("https://chatgpt.com/backend-api");
+ WinHarness.Infrastructure.Configuration.WinHarnessOptionsValidator.Validate(options);
+ }
+
+ [TestMethod]
+ public void FactoryRoutesOpenAiCodexKind()
+ {
+ WinHarnessOptions options = CreateOptions("https://chatgpt.com/backend-api");
+ FakeCredentialStore store = new(CreateTokenSet());
+ OpenAiCompatibleProviderFactory factory = new(options, store, [new OpenAiCodexOAuthFlow(new HttpClient())]);
+ IChatProvider provider = factory.Create("openai-codex", "gpt-5.4");
+ Assert.AreEqual("openai-codex", provider.ProviderId);
+ using IChatClient client = provider.CreateChatClient();
+ Assert.IsInstanceOfType(client, typeof(IChatClient));
+ }
+
+ private static OAuthTokenSet CreateTokenSet() =>
+ new(
+ AccessToken: "access-token",
+ RefreshToken: "refresh-token",
+ ExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
+ BaseUrl: OpenAiCodexOAuthFlow.DefaultBaseUrl,
+ AccountId: "acct-test");
+
+ private static WinHarnessOptions CreateOptions(string baseUrl)
+ {
+ WinHarnessOptions options = new();
+ ProviderOptions provider = new()
+ {
+ Id = "openai-codex",
+ Kind = "openai-codex-responses",
+ BaseUrl = baseUrl.TrimEnd('/'),
+ Auth = new ProviderAuthOptions { Scheme = "oauth", OAuthProvider = OpenAiCodexOAuthFlow.ProviderId }
+ };
+ provider.Models.Add(new ModelOptions
+ {
+ Id = "gpt-5.4",
+ ProviderModelId = "gpt-5.4",
+ Capabilities = new ProviderCapabilities(
+ Streaming: true,
+ ToolCalling: true,
+ Vision: false,
+ PromptCaching: true,
+ StructuredOutput: false,
+ Reasoning: true)
+ });
+ options.Providers.Add(provider);
+ return options;
+ }
+
+ private sealed class FakeCredentialStore : ICredentialStore
+ {
+ private readonly string _tokenJson;
+
+ public FakeCredentialStore(OAuthTokenSet tokens)
+ {
+ _tokenJson = JsonSerializer.Serialize(tokens, WinHarnessJsonSerializerContext.Default.OAuthTokenSet);
+ }
+
+ public ValueTask GetSecretAsync(string targetName, CancellationToken cancellationToken)
+ => ValueTask.FromResult(_tokenJson);
+
+ public ValueTask SetSecretAsync(string targetName, string secret, CancellationToken cancellationToken)
+ => ValueTask.CompletedTask;
+
+ public ValueTask DeleteSecretAsync(string targetName, CancellationToken cancellationToken)
+ => ValueTask.CompletedTask;
+
+ public ValueTask> ListTargetNamesAsync(CancellationToken cancellationToken)
+ => ValueTask.FromResult>([OAuthCredentialNames.ForProvider(OpenAiCodexOAuthFlow.ProviderId)]);
+ }
+
+ private sealed class FakeCodexServer : IAsyncDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly Task _serverTask;
+ private readonly string _sseBody;
+
+ private FakeCodexServer(TcpListener listener, Task serverTask, string sseBody)
+ {
+ _listener = listener;
+ _serverTask = serverTask;
+ _sseBody = sseBody;
+ }
+
+ public Uri Endpoint
+ {
+ get
+ {
+ IPEndPoint endpoint = (IPEndPoint)_listener.LocalEndpoint;
+ return new Uri($"http://127.0.0.1:{endpoint.Port}");
+ }
+ }
+
+ public string? LastRequestBody { get; private set; }
+
+ public Dictionary LastHeaders { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ public static Task StartAsync(CancellationToken cancellationToken)
+ {
+ const string body = """
+ event: response.created
+ data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4"}}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","delta":"Hello from "}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","delta":"Codex"}
+
+ event: response.completed
+ data: {"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.4","usage":{"input_tokens":20,"output_tokens":7,"total_tokens":27}}}
+
+ """;
+ return StartCoreAsync(body, cancellationToken);
+ }
+
+ public static Task StartToolUseAsync(CancellationToken cancellationToken)
+ {
+ const string body = """
+ event: response.output_item.added
+ data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":""}}
+
+ event: response.function_call_arguments.delta
+ data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"location\":"}
+
+ event: response.function_call_arguments.delta
+ data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"\"SF\"}"}
+
+ event: response.output_item.done
+ data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"SF\"}"}}
+
+ event: response.completed
+ data: {"type":"response.completed","response":{"id":"resp_2","usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}
+
+ """;
+ return StartCoreAsync(body, cancellationToken);
+ }
+
+ private static Task StartCoreAsync(string body, CancellationToken cancellationToken)
+ {
+ TcpListener listener = new(IPAddress.Loopback, 0);
+ listener.Start();
+ TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Task task = Task.Run(async () =>
+ {
+ FakeCodexServer server = await ready.Task.ConfigureAwait(false);
+ await server.AcceptOneRequestAsync(cancellationToken).ConfigureAwait(false);
+ }, cancellationToken);
+
+ FakeCodexServer fake = new(listener, task, body);
+ ready.SetResult(fake);
+ return Task.FromResult(fake);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _listener.Stop();
+ try
+ {
+ await _serverTask.ConfigureAwait(false);
+ }
+ catch (SocketException)
+ {
+ }
+ }
+
+ private async Task AcceptOneRequestAsync(CancellationToken cancellationToken)
+ {
+ using TcpClient client = await _listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false);
+ await using NetworkStream stream = client.GetStream();
+ await ReadHttpRequestAsync(stream, cancellationToken).ConfigureAwait(false);
+
+ string response = string.Concat(
+ "HTTP/1.1 200 OK\r\n",
+ "Content-Type: text/event-stream; charset=utf-8\r\n",
+ "Connection: close\r\n",
+ "\r\n",
+ _sseBody);
+
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(response), cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ReadHttpRequestAsync(NetworkStream stream, CancellationToken cancellationToken)
+ {
+ byte[] buffer = new byte[16_384];
+ int total = 0;
+ while (total < buffer.Length)
+ {
+ int read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken)
+ .ConfigureAwait(false);
+ if (read == 0)
+ {
+ break;
+ }
+
+ total += read;
+ int headerEnd = buffer.AsSpan(0, total).IndexOf("\r\n\r\n"u8);
+ if (headerEnd < 0)
+ {
+ continue;
+ }
+
+ string headerText = Encoding.UTF8.GetString(buffer, 0, headerEnd);
+ foreach (string line in headerText.Split("\r\n"))
+ {
+ int colon = line.IndexOf(':');
+ if (colon > 0)
+ {
+ LastHeaders[line[..colon].Trim()] = line[(colon + 1)..].Trim();
+ }
+ }
+
+ int contentLength = 0;
+ if (LastHeaders.TryGetValue("Content-Length", out string? lengthText))
+ {
+ _ = int.TryParse(lengthText, out contentLength);
+ }
+
+ int bodyStart = headerEnd + 4;
+ while (total - bodyStart < contentLength && total < buffer.Length)
+ {
+ int more = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken)
+ .ConfigureAwait(false);
+ if (more == 0)
+ {
+ break;
+ }
+
+ total += more;
+ }
+
+ if (contentLength > 0)
+ {
+ LastRequestBody = Encoding.UTF8.GetString(buffer, bodyStart, Math.Min(contentLength, total - bodyStart));
+ }
+
+ return;
+ }
+ }
+ }
+}
diff --git a/tests/WinHarness.UnitTests/OpenAiCodexOAuthFlowTests.cs b/tests/WinHarness.UnitTests/OpenAiCodexOAuthFlowTests.cs
new file mode 100644
index 0000000..613693f
--- /dev/null
+++ b/tests/WinHarness.UnitTests/OpenAiCodexOAuthFlowTests.cs
@@ -0,0 +1,183 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using WinHarness.Providers;
+
+namespace WinHarness.UnitTests;
+
+[TestClass]
+public sealed class OpenAiCodexOAuthFlowTests
+{
+ [TestMethod]
+ public void CreatePkceSessionBuildsAuthorizeUrl()
+ {
+ OpenAiCodexOAuthFlow flow = new(new HttpClient(new FakeHandler()));
+ OpenAiCodexPkceSession session = flow.CreatePkceSession();
+
+ StringAssert.StartsWith(session.AuthorizeUrl, OpenAiCodexOAuthFlow.AuthorizeUrl);
+ StringAssert.Contains(session.AuthorizeUrl, "code_challenge_method=S256");
+ StringAssert.Contains(session.AuthorizeUrl, Uri.EscapeDataString(OpenAiCodexOAuthFlow.RedirectUri));
+ StringAssert.Contains(session.AuthorizeUrl, "originator=winharness");
+ StringAssert.Contains(session.AuthorizeUrl, "codex_cli_simplified_flow=true");
+ Assert.IsFalse(string.IsNullOrWhiteSpace(session.Challenge));
+ Assert.IsFalse(string.IsNullOrWhiteSpace(session.State));
+ }
+
+ [TestMethod]
+ public void ParseAuthorizationInputAcceptsRedirectUrl()
+ {
+ OpenAiCodexPkceSession session = new("verifier", "challenge", "state-1", "https://example");
+ string input = $"{OpenAiCodexOAuthFlow.RedirectUri}?code=abc123&state=state-1";
+
+ OpenAiCodexCallbackResult result = OpenAiCodexOAuthFlow.ParseAuthorizationInput(input, session);
+
+ Assert.AreEqual("abc123", result.Code);
+ Assert.AreEqual("state-1", result.State);
+ }
+
+ [TestMethod]
+ public void ParseAuthorizationInputAcceptsHashForm()
+ {
+ OpenAiCodexPkceSession session = new("verifier", "challenge", "state-1", "https://example");
+
+ OpenAiCodexCallbackResult result = OpenAiCodexOAuthFlow.ParseAuthorizationInput("code1#state-1", session);
+
+ Assert.AreEqual("code1", result.Code);
+ Assert.AreEqual("state-1", result.State);
+ }
+
+ [TestMethod]
+ public void ParseAuthorizationInputRejectsStateMismatch()
+ {
+ OpenAiCodexPkceSession session = new("verifier", "challenge", "state-1", "https://example");
+
+ Assert.ThrowsExactly(
+ () => OpenAiCodexOAuthFlow.ParseAuthorizationInput(
+ $"{OpenAiCodexOAuthFlow.RedirectUri}?code=abc&state=other",
+ session));
+ }
+
+ [TestMethod]
+ public void ExtractAccountIdReadsJwtClaim()
+ {
+ string token = CreateJwtWithAccount("acct-42");
+ Assert.AreEqual("acct-42", OpenAiCodexOAuthFlow.ExtractAccountId(token));
+ }
+
+ [TestMethod]
+ public async Task ExchangeCodeProducesTokenSetWithAccountId()
+ {
+ string access = CreateJwtWithAccount("acct-1");
+ string body = "{\"access_token\":\"" + access + "\",\"refresh_token\":\"refresh-1\",\"expires_in\":3600}";
+ FakeHandler handler = new(body);
+ OpenAiCodexOAuthFlow flow = new(new HttpClient(handler));
+ OpenAiCodexPkceSession session = new("verifier", "challenge", "state-1", "https://example");
+ OpenAiCodexCallbackResult callback = new("auth-code", "state-1");
+
+ OAuthTokenSet tokens = await flow.ExchangeCodeAsync(session, callback, CancellationToken.None);
+
+ Assert.AreEqual(access, tokens.AccessToken);
+ Assert.AreEqual("refresh-1", tokens.RefreshToken);
+ Assert.AreEqual("acct-1", tokens.AccountId);
+ Assert.AreEqual(OpenAiCodexOAuthFlow.DefaultBaseUrl, tokens.BaseUrl);
+ Assert.IsTrue(tokens.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(30));
+ StringAssert.Contains(handler.LastBody!, "authorization_code");
+ StringAssert.Contains(handler.LastBody!, "auth-code");
+ }
+
+ [TestMethod]
+ public async Task RefreshProducesRotatedTokens()
+ {
+ string access = CreateJwtWithAccount("acct-new");
+ string body = "{\"access_token\":\"" + access + "\",\"refresh_token\":\"refresh-new\",\"expires_in\":1800}";
+ FakeHandler handler = new(body);
+ OpenAiCodexOAuthFlow flow = new(new HttpClient(handler));
+ OAuthTokenSet current = new("old", "refresh-keep", DateTimeOffset.UtcNow, AccountId: "acct-old");
+
+ OAuthTokenSet tokens = await flow.RefreshAsync(current, CancellationToken.None);
+
+ Assert.AreEqual(access, tokens.AccessToken);
+ Assert.AreEqual("refresh-new", tokens.RefreshToken);
+ Assert.AreEqual("acct-new", tokens.AccountId);
+ StringAssert.Contains(handler.LastBody!, "refresh_token");
+ }
+
+ [TestMethod]
+ public async Task RefreshPreservesRefreshTokenAndAccountIdWhenOmitted()
+ {
+ // Access JWT without chatgpt_account_id; refresh response omits refresh_token.
+ string header = Base64Url(Encoding.UTF8.GetBytes("""{"alg":"none","typ":"JWT"}"""));
+ string bodyPayload = Base64Url(Encoding.UTF8.GetBytes("""{"sub":"user"}"""));
+ string access = $"{header}.{bodyPayload}.sig";
+ string body = "{\"access_token\":\"" + access + "\",\"expires_in\":1800}";
+ FakeHandler handler = new(body);
+ OpenAiCodexOAuthFlow flow = new(new HttpClient(handler));
+ OAuthTokenSet current = new("old", "refresh-keep", DateTimeOffset.UtcNow, AccountId: "acct-keep");
+
+ OAuthTokenSet tokens = await flow.RefreshAsync(current, CancellationToken.None);
+
+ Assert.AreEqual(access, tokens.AccessToken);
+ Assert.AreEqual("refresh-keep", tokens.RefreshToken);
+ Assert.AreEqual("acct-keep", tokens.AccountId);
+ }
+
+ [TestMethod]
+ public async Task RefreshWithoutStoredTokenIsActionable()
+ {
+ OpenAiCodexOAuthFlow flow = new(new HttpClient(new FakeHandler()));
+
+ InvalidOperationException exception = await Assert.ThrowsExactlyAsync(
+ async () => await flow.RefreshAsync(
+ new OAuthTokenSet("bearer", RefreshToken: null, ExpiresAt: null),
+ CancellationToken.None));
+
+ StringAssert.Contains(exception.Message, "winharness login --provider openai");
+ }
+
+ [TestMethod]
+ public void DefaultModelsAreSeeded()
+ {
+ Assert.IsTrue(OpenAiCodexOAuthFlow.DefaultModels.Count >= 2);
+ Assert.IsTrue(OpenAiCodexOAuthFlow.DefaultModels.Any(model => model.Id.Contains("gpt", StringComparison.OrdinalIgnoreCase)));
+ }
+
+ private static string CreateJwtWithAccount(string accountId)
+ {
+ // Minimal unsigned JWT: header.payload.sig with chatgpt_account_id claim.
+ string header = Base64Url(Encoding.UTF8.GetBytes("""{"alg":"none","typ":"JWT"}"""));
+ string payloadJson = "{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"" + accountId + "\"}}";
+ string body = Base64Url(Encoding.UTF8.GetBytes(payloadJson));
+ return $"{header}.{body}.sig";
+ }
+
+ private static string Base64Url(byte[] data) =>
+ Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private sealed class FakeHandler : HttpMessageHandler
+ {
+ private readonly Queue _responses;
+
+ public FakeHandler(params string[] responses)
+ {
+ _responses = new Queue(responses);
+ }
+
+ public string? LastBody { get; private set; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ LastBody = request.Content is null
+ ? null
+ : await request.Content.ReadAsStringAsync(cancellationToken);
+
+ string body = _responses.Count > 0 ? _responses.Dequeue() : "{}";
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ };
+ }
+ }
+}