diff --git a/CONTEXT.md b/CONTEXT.md index a28a63a..4f0e449 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -38,7 +38,7 @@ User input queued while a Turn is running (prefix `>>`), delivered as the next T A session entry that replaces older messages in the *active context* with a summary. Full history remains in the JSONL file. Triggered manually (`/compact`) or automatically (proactive near the model's context window, or reactive retry-once on a provider context-overflow failure). **Provider**: -A configured model endpoint (id, kind, base URL, optional credential). Kinds: `openai-compatible` and `anthropic-messages`. Distinct from Model. +A configured model endpoint (id, kind, base URL, optional credential). Kinds: `openai-compatible` and `anthropic-messages / openai-codex-responses`. Distinct from Model. _Avoid_: Backend, vendor, service. **Model**: diff --git a/README.md b/README.md index df8f4f9..4114898 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ dotnet publish .\src\WinHarness.Cli\WinHarness.Cli.csproj -c Release -r win-x64 - `winharness mcp tools` - `winharness login --provider copilot [--enterprise-domain ghe.example.com]` — GitHub Copilot subscription auth via device code flow (see [Subscription auth](#subscription-auth-oauth)) - `winharness login --provider anthropic` — Claude Pro/Max OAuth (PKCE + loopback; paste fallback) +- `winharness login --provider openai` (alias: `codex`) — ChatGPT Plus/Pro Codex OAuth - `winharness login status` / `winharness logout --provider copilot` - `winharness credentials set|get|list|delete` @@ -241,7 +242,7 @@ WinHarness credential target names must use the `WinHarness:` prefix, for exampl `winharness login --provider anthropic` signs in with Claude Pro/Max via PKCE on a fixed-port loopback (`http://localhost:53692/`). Press Enter while waiting to paste the redirect URL instead (SSH/remote). Tokens are stored under `WinHarness:oauth:anthropic`; the command seeds an `anthropic` provider with `kind: anthropic-messages`. -OpenAI ChatGPT/Codex OAuth lands with PR-B4 (`login --provider openai`) — see `docs/adr/ADR-0005-oauth-subscription-providers.md`. +`winharness login --provider openai` (or `codex`) signs in with ChatGPT Plus/Pro via PKCE on `http://localhost:1455/auth/callback`. Tokens are stored under `WinHarness:oauth:openai-codex`; the command seeds an `openai-codex` provider with `kind: openai-codex-responses` (Responses API). See `docs/adr/ADR-0005-oauth-subscription-providers.md`. > **Note:** subscription auth rides the unofficial endpoints the vendors ship for their own CLIs. They can change or be revoked at any time (ADR-0005 records this risk acceptance). diff --git a/docs/design/pi-parity-roadmap.md b/docs/design/pi-parity-roadmap.md index 0f9366e..f9e49fb 100644 --- a/docs/design/pi-parity-roadmap.md +++ b/docs/design/pi-parity-roadmap.md @@ -149,7 +149,7 @@ stores the token set, and auto-creates/updates the `copilot` provider entry; them. REPL `/login` deferred — the CLI command works while chat is closed, which covers the core need. Anthropic/OpenAI flows land with PR-B3/PR-B4. -### 4.3 Non-OpenAI-compatible transports (PR-B3: DONE — Anthropic Messages) +### 4.3 Non-OpenAI-compatible transports (PR-B3: DONE — Anthropic Messages; PR-B4: DONE — OpenAI Codex) Copilot works over the existing OpenAI-compatible pipeline. Anthropic (Messages API) and OpenAI subscription (Responses API) do **not**. Options: diff --git a/src/WinHarness.Abstractions/Configuration/WinHarnessOptions.cs b/src/WinHarness.Abstractions/Configuration/WinHarnessOptions.cs index 3eaeb66..854a4c7 100644 --- a/src/WinHarness.Abstractions/Configuration/WinHarnessOptions.cs +++ b/src/WinHarness.Abstractions/Configuration/WinHarnessOptions.cs @@ -70,12 +70,14 @@ public sealed class ProviderOptions public string Id { get; set; } = string.Empty; /// - /// Gets or sets the provider kind: "openai-compatible" or "anthropic-messages". + /// Gets or sets the provider kind: "openai-compatible", "anthropic-messages", + /// or "openai-codex-responses". /// public string Kind { get; set; } = string.Empty; /// - /// Gets or sets the OpenAI-compatible base URL. + /// Gets or sets the provider base URL (OpenAI-compatible root, Anthropic + /// API host, or Codex backend host). /// public string? BaseUrl { get; set; } diff --git a/src/WinHarness.Cli/Program.cs b/src/WinHarness.Cli/Program.cs index 6ea4774..6b57bda 100644 --- a/src/WinHarness.Cli/Program.cs +++ b/src/WinHarness.Cli/Program.cs @@ -711,9 +711,16 @@ await ConfigFileUpdater.SetRootStringPropertiesAsync( return; } + if (string.Equals(provider, "openai", StringComparison.OrdinalIgnoreCase) || + string.Equals(provider, "codex", StringComparison.OrdinalIgnoreCase)) + { + await LoginOpenAiCodexAsync(host.Services, cancellationToken).ConfigureAwait(false); + return; + } + if (!string.Equals(provider, "copilot", StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException($"OAuth provider '{provider}' is not supported yet. Available: copilot, anthropic."); + throw new InvalidOperationException($"OAuth provider '{provider}' is not supported yet. Available: copilot, anthropic, openai."); } ICredentialStore store = host.Services.GetRequiredService(); @@ -1054,6 +1061,83 @@ static async Task WaitForAnthropicCallbackOrPasteAsync( } +static async Task LoginOpenAiCodexAsync(IServiceProvider services, CancellationToken cancellationToken) +{ + ICredentialStore store = services.GetRequiredService(); + ConfigStore configStore = services.GetRequiredService(); + using HttpClient http = new(); + OpenAiCodexOAuthFlow flow = new(http); + OpenAiCodexPkceSession session = flow.CreatePkceSession(); + + AnsiConsole.MarkupLine($"Open [bold blue]{Markup.Escape(session.AuthorizeUrl)}[/] to authorize ChatGPT Plus/Pro (Codex)."); + AnsiConsole.MarkupLine("[dim]Waiting for browser callback… (Ctrl+C to cancel, or paste the redirect URL when prompted)[/]"); + + OpenAiCodexCallbackResult callback; + try + { + callback = await flow.WaitForCallbackAsync(session, cancellationToken).ConfigureAwait(false); + } + catch (InvalidOperationException bindError) when (bindError.Message.Contains("Could not bind OAuth callback", StringComparison.Ordinal)) + { + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(bindError.Message)}[/]"); + string pasted = AnsiConsole.Ask("Paste the authorization code or full redirect URL:"); + callback = OpenAiCodexOAuthFlow.ParseAuthorizationInput(pasted, session); + } + + OAuthTokenSet tokens = await flow.ExchangeCodeAsync(session, callback, cancellationToken).ConfigureAwait(false); + + const string providerId = "openai"; + await store.SetSecretAsync( + OAuthCredentialNames.ForProvider(providerId), + JsonSerializer.Serialize(tokens, WinHarnessJsonSerializerContext.Default.OAuthTokenSet), + cancellationToken).ConfigureAwait(false); + + WinHarnessOptions current = await configStore.LoadAsync(cancellationToken).ConfigureAwait(false); + ProviderOptions? existing = current.Providers.FirstOrDefault(candidate => + string.Equals(candidate.Id, providerId, StringComparison.OrdinalIgnoreCase)); + if (existing is null) + { + existing = new ProviderOptions { Id = providerId, Kind = "openai-codex-responses" }; + current.Providers.Add(existing); + } + + existing.Kind = "openai-codex-responses"; + existing.BaseUrl = tokens.BaseUrl ?? OpenAiCodexOAuthFlow.DefaultBaseUrl; + existing.Auth = new ProviderAuthOptions { Scheme = "oauth", OAuthProvider = "openai" }; + existing.CredentialName = null; + + if (existing.Models.Count == 0) + { + foreach (ModelSeed seed in OpenAiCodexOAuthFlow.DefaultModels) + { + existing.Models.Add(new ModelOptions + { + Id = seed.Id, + ProviderModelId = seed.ProviderModelId, + Capabilities = new ProviderCapabilities( + Streaming: true, + ToolCalling: true, + Vision: true, + PromptCaching: true, + StructuredOutput: false, + Reasoning: seed.Reasoning), + ContextWindow = seed.ContextWindow, + SupportedReasoningEfforts = seed.Reasoning ? ["minimal", "low", "medium", "high", "extra-high"] : null, + }); + } + } + + if (string.IsNullOrWhiteSpace(current.DefaultProvider)) + { + current.DefaultProvider = providerId; + current.DefaultModel = existing.Models[0].Id; + } + + await configStore.SaveAsync(current, cancellationToken).ConfigureAwait(false); + AnsiConsole.MarkupLine($"[green]Logged in.[/] Provider '{providerId}' configured at {Markup.Escape(existing.BaseUrl)}."); + AnsiConsole.MarkupLine($"[dim]Seeded {existing.Models.Count} Codex models. Account: {Markup.Escape(tokens.AccountId ?? "?")}. Switch with: winharness models use {existing.Models[0].Id} --provider-id openai[/]"); +} + static async Task LoginAnthropicAsync(IServiceProvider services, CancellationToken cancellationToken) { ICredentialStore store = services.GetRequiredService(); diff --git a/src/WinHarness.Infrastructure/Configuration/WinHarnessOptionsValidator.cs b/src/WinHarness.Infrastructure/Configuration/WinHarnessOptionsValidator.cs index d3da1eb..e4e4438 100644 --- a/src/WinHarness.Infrastructure/Configuration/WinHarnessOptionsValidator.cs +++ b/src/WinHarness.Infrastructure/Configuration/WinHarnessOptionsValidator.cs @@ -1,145 +1,146 @@ -using WinHarness.Configuration; - -namespace WinHarness.Infrastructure.Configuration; - -/// -/// Validates WinHarness options without reflection. -/// -public static class WinHarnessOptionsValidator -{ - /// - /// Validates options and throws when invalid. - /// - public static void Validate(WinHarnessOptions options) - { - ArgumentNullException.ThrowIfNull(options); - - HashSet providerIds = new(StringComparer.OrdinalIgnoreCase); - foreach (ProviderOptions provider in options.Providers) - { - RequireNonEmpty(provider.Id, "Provider id is required."); - RequireNonEmpty(provider.Kind, $"Provider '{provider.Id}' kind is required."); - - if (!IsSupportedProviderKind(provider.Kind)) - { - throw new InvalidOperationException( - $"Provider '{provider.Id}' uses unsupported kind '{provider.Kind}'. Supported kinds: openai-compatible, anthropic-messages."); - } - - if (!providerIds.Add(provider.Id)) - { - throw new InvalidOperationException($"Duplicate provider id '{provider.Id}'."); - } - - if (provider.BaseUrl is not null && !Uri.TryCreate(provider.BaseUrl, UriKind.Absolute, out _)) - { - throw new InvalidOperationException($"Provider '{provider.Id}' baseUrl is not an absolute URI."); - } - - if (provider.CredentialName is not null && - !provider.CredentialName.StartsWith("WinHarness:", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"Provider '{provider.Id}' credentialName must start with 'WinHarness:'."); - } - - if (provider.Auth is { } auth) - { - bool isApiKey = string.Equals(auth.Scheme, "api-key", StringComparison.OrdinalIgnoreCase); - bool isOAuth = string.Equals(auth.Scheme, "oauth", StringComparison.OrdinalIgnoreCase); - if (!isApiKey && !isOAuth) - { - throw new InvalidOperationException($"Provider '{provider.Id}' auth scheme '{auth.Scheme}' is not supported. Use api-key or oauth."); - } - - if (isOAuth && string.IsNullOrWhiteSpace(auth.OAuthProvider)) - { - throw new InvalidOperationException($"Provider '{provider.Id}' uses the oauth scheme but is missing auth.oauthProvider."); - } - } - - HashSet modelIds = new(StringComparer.OrdinalIgnoreCase); - foreach (ModelOptions model in provider.Models) - { - RequireNonEmpty(model.Id, $"Provider '{provider.Id}' has a model without an id."); - RequireNonEmpty(model.ProviderModelId, $"Model '{model.Id}' is missing providerModelId."); - - if (!modelIds.Add(model.Id)) - { - throw new InvalidOperationException($"Provider '{provider.Id}' has duplicate model id '{model.Id}'."); - } - } - } - - if (options.DefaultProvider.Length > 0 && !providerIds.Contains(options.DefaultProvider)) - { - throw new InvalidOperationException($"Default provider '{options.DefaultProvider}' is not configured."); - } - - if (options.DefaultProvider.Length > 0 && options.DefaultModel.Length > 0) - { - ProviderOptions defaultProvider = options.Providers.First(provider => - string.Equals(provider.Id, options.DefaultProvider, StringComparison.OrdinalIgnoreCase)); - bool hasDefaultModel = defaultProvider.Models.Any(model => - string.Equals(model.Id, options.DefaultModel, StringComparison.OrdinalIgnoreCase)); - if (!hasDefaultModel) - { - throw new InvalidOperationException($"Default model '{options.DefaultModel}' is not configured for provider '{options.DefaultProvider}'."); - } - } - - HashSet mcpServerIds = new(StringComparer.OrdinalIgnoreCase); - foreach (McpServerOptions server in options.McpServers) - { - RequireNonEmpty(server.Id, "MCP server id is required."); - RequireNonEmpty(server.Transport, $"MCP server '{server.Id}' transport is required."); - - if (!mcpServerIds.Add(server.Id)) - { - throw new InvalidOperationException($"Duplicate MCP server id '{server.Id}'."); - } - - if (IsStdioTransport(server.Transport)) - { - RequireNonEmpty(server.Command, $"MCP server '{server.Id}' command is required for stdio transport."); - } - else if (IsHttpTransport(server.Transport)) - { - RequireNonEmpty(server.Endpoint ?? string.Empty, $"MCP server '{server.Id}' endpoint is required for {server.Transport} transport."); - if (!Uri.TryCreate(server.Endpoint, UriKind.Absolute, out Uri? endpoint) || - (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps)) - { - throw new InvalidOperationException($"MCP server '{server.Id}' endpoint must be an absolute HTTP or HTTPS URI."); - } - } - else - { - throw new InvalidOperationException($"MCP server '{server.Id}' uses unsupported transport '{server.Transport}'. Supported values are stdio, http, and sse."); - } - } - } - - private static bool IsSupportedProviderKind(string kind) - { - return string.Equals(kind, "openai-compatible", StringComparison.OrdinalIgnoreCase) || - string.Equals(kind, "anthropic-messages", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsStdioTransport(string transport) - { - return string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsHttpTransport(string transport) - { - return string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase) || - string.Equals(transport, "sse", StringComparison.OrdinalIgnoreCase); - } - - private static void RequireNonEmpty(string value, string message) - { - if (string.IsNullOrWhiteSpace(value)) - { - throw new InvalidOperationException(message); - } - } -} +using WinHarness.Configuration; + +namespace WinHarness.Infrastructure.Configuration; + +/// +/// Validates WinHarness options without reflection. +/// +public static class WinHarnessOptionsValidator +{ + /// + /// Validates options and throws when invalid. + /// + public static void Validate(WinHarnessOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + HashSet providerIds = new(StringComparer.OrdinalIgnoreCase); + foreach (ProviderOptions provider in options.Providers) + { + RequireNonEmpty(provider.Id, "Provider id is required."); + RequireNonEmpty(provider.Kind, $"Provider '{provider.Id}' kind is required."); + + if (!IsSupportedProviderKind(provider.Kind)) + { + throw new InvalidOperationException( + $"Provider '{provider.Id}' uses unsupported kind '{provider.Kind}'. Supported kinds: openai-compatible, anthropic-messages, openai-codex-responses."); + } + + if (!providerIds.Add(provider.Id)) + { + throw new InvalidOperationException($"Duplicate provider id '{provider.Id}'."); + } + + if (provider.BaseUrl is not null && !Uri.TryCreate(provider.BaseUrl, UriKind.Absolute, out _)) + { + throw new InvalidOperationException($"Provider '{provider.Id}' baseUrl is not an absolute URI."); + } + + if (provider.CredentialName is not null && + !provider.CredentialName.StartsWith("WinHarness:", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Provider '{provider.Id}' credentialName must start with 'WinHarness:'."); + } + + if (provider.Auth is { } auth) + { + bool isApiKey = string.Equals(auth.Scheme, "api-key", StringComparison.OrdinalIgnoreCase); + bool isOAuth = string.Equals(auth.Scheme, "oauth", StringComparison.OrdinalIgnoreCase); + if (!isApiKey && !isOAuth) + { + throw new InvalidOperationException($"Provider '{provider.Id}' auth scheme '{auth.Scheme}' is not supported. Use api-key or oauth."); + } + + if (isOAuth && string.IsNullOrWhiteSpace(auth.OAuthProvider)) + { + throw new InvalidOperationException($"Provider '{provider.Id}' uses the oauth scheme but is missing auth.oauthProvider."); + } + } + + HashSet modelIds = new(StringComparer.OrdinalIgnoreCase); + foreach (ModelOptions model in provider.Models) + { + RequireNonEmpty(model.Id, $"Provider '{provider.Id}' has a model without an id."); + RequireNonEmpty(model.ProviderModelId, $"Model '{model.Id}' is missing providerModelId."); + + if (!modelIds.Add(model.Id)) + { + throw new InvalidOperationException($"Provider '{provider.Id}' has duplicate model id '{model.Id}'."); + } + } + } + + if (options.DefaultProvider.Length > 0 && !providerIds.Contains(options.DefaultProvider)) + { + throw new InvalidOperationException($"Default provider '{options.DefaultProvider}' is not configured."); + } + + if (options.DefaultProvider.Length > 0 && options.DefaultModel.Length > 0) + { + ProviderOptions defaultProvider = options.Providers.First(provider => + string.Equals(provider.Id, options.DefaultProvider, StringComparison.OrdinalIgnoreCase)); + bool hasDefaultModel = defaultProvider.Models.Any(model => + string.Equals(model.Id, options.DefaultModel, StringComparison.OrdinalIgnoreCase)); + if (!hasDefaultModel) + { + throw new InvalidOperationException($"Default model '{options.DefaultModel}' is not configured for provider '{options.DefaultProvider}'."); + } + } + + HashSet mcpServerIds = new(StringComparer.OrdinalIgnoreCase); + foreach (McpServerOptions server in options.McpServers) + { + RequireNonEmpty(server.Id, "MCP server id is required."); + RequireNonEmpty(server.Transport, $"MCP server '{server.Id}' transport is required."); + + if (!mcpServerIds.Add(server.Id)) + { + throw new InvalidOperationException($"Duplicate MCP server id '{server.Id}'."); + } + + if (IsStdioTransport(server.Transport)) + { + RequireNonEmpty(server.Command, $"MCP server '{server.Id}' command is required for stdio transport."); + } + else if (IsHttpTransport(server.Transport)) + { + RequireNonEmpty(server.Endpoint ?? string.Empty, $"MCP server '{server.Id}' endpoint is required for {server.Transport} transport."); + if (!Uri.TryCreate(server.Endpoint, UriKind.Absolute, out Uri? endpoint) || + (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps)) + { + throw new InvalidOperationException($"MCP server '{server.Id}' endpoint must be an absolute HTTP or HTTPS URI."); + } + } + else + { + throw new InvalidOperationException($"MCP server '{server.Id}' uses unsupported transport '{server.Transport}'. Supported values are stdio, http, and sse."); + } + } + } + + private static bool IsSupportedProviderKind(string kind) + { + return string.Equals(kind, "openai-compatible", StringComparison.OrdinalIgnoreCase) || + string.Equals(kind, "anthropic-messages", StringComparison.OrdinalIgnoreCase) || + string.Equals(kind, "openai-codex-responses", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsStdioTransport(string transport) + { + return string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsHttpTransport(string transport) + { + return string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase) || + string.Equals(transport, "sse", StringComparison.OrdinalIgnoreCase); + } + + private static void RequireNonEmpty(string value, string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException(message); + } + } +} diff --git a/src/WinHarness.Providers/OpenAiCodexOAuthFlow.cs b/src/WinHarness.Providers/OpenAiCodexOAuthFlow.cs new file mode 100644 index 0000000..dafe817 --- /dev/null +++ b/src/WinHarness.Providers/OpenAiCodexOAuthFlow.cs @@ -0,0 +1,490 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace WinHarness.Providers; + +/// +/// OpenAI Codex (ChatGPT Plus/Pro) OAuth PKCE flow and refresh grant. +/// Endpoints, client id, scopes, and callback port verified against pi's +/// shipping implementation (ADR-0005). All vendor knowledge lives here so +/// drift is a single-file fix. +/// +public sealed class OpenAiCodexOAuthFlow : IOAuthTokenRefresher +{ + // Codex CLI client id, base64-obfuscated per ADR-0005. + private static readonly string ClientId = + Encoding.UTF8.GetString(Convert.FromBase64String("YXBwX0VNb2FtRUVaNzNmMENrWGFYcDdocmFubg==")); + + /// Authorize endpoint for ChatGPT / Codex subscription login. + public const string AuthorizeUrl = "https://auth.openai.com/oauth/authorize"; + + /// Token + refresh endpoint. + public const string TokenUrl = "https://auth.openai.com/oauth/token"; + + /// Fixed loopback port registered by Codex CLI. + public const int CallbackPort = 1455; + + /// Callback path on the loopback listener. + public const string CallbackPath = "/auth/callback"; + + /// Redirect URI sent to OpenAI (localhost, not 127.0.0.1). + public static string RedirectUri => $"http://localhost:{CallbackPort}{CallbackPath}"; + + /// OAuth scopes required for Codex Responses inference. + public const string Scopes = "openid profile email offline_access"; + + /// Default Codex backend base URL after login. + public const string DefaultBaseUrl = "https://chatgpt.com/backend-api"; + + /// JWT claim namespace that carries chatgpt_account_id. + public const string JwtAuthClaimPath = "https://api.openai.com/auth"; + + /// + /// Originator header value sent on Codex Responses requests and the OAuth + /// authorize URL. Kept stable so vendor drift is one constant. + /// + public const string Originator = "winharness"; + + /// Required headers for Codex Responses requests (minus auth/account). + public static readonly IReadOnlyDictionary RequestHeaders = + new Dictionary(StringComparer.Ordinal) + { + ["originator"] = Originator, + ["OpenAI-Beta"] = "responses=experimental", + }; + + private readonly HttpClient _http; + + /// Creates the flow. + public OpenAiCodexOAuthFlow(HttpClient http) + { + _http = http; + } + + /// + public string OAuthProviderId => ProviderId; + + /// Canonical oauthProvider / credential provider id for Codex. + public const string ProviderId = "openai-codex"; + + /// + /// Builds the browser authorize URL and PKCE pair. The caller opens the URL + /// and either waits on or accepts a + /// pasted code/redirect URL. + /// + public OpenAiCodexPkceSession CreatePkceSession() + { + string verifier = CreateCodeVerifier(); + string challenge = CreateCodeChallenge(verifier); + string state = CreateState(); + + var query = new Dictionary + { + ["response_type"] = "code", + ["client_id"] = ClientId, + ["redirect_uri"] = RedirectUri, + ["scope"] = Scopes, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + ["state"] = state, + ["id_token_add_organizations"] = "true", + ["codex_cli_simplified_flow"] = "true", + ["originator"] = Originator, + }; + + string url = AuthorizeUrl + "?" + string.Join("&", query.Select(static pair => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); + + return new OpenAiCodexPkceSession(verifier, challenge, state, url); + } + + /// + /// Starts a loopback on the fixed Codex callback + /// port and waits for the authorization code. + /// + public async ValueTask WaitForCallbackAsync( + OpenAiCodexPkceSession session, + CancellationToken cancellationToken) + { + using HttpListener listener = new(); + listener.Prefixes.Add($"http://127.0.0.1:{CallbackPort}/"); + listener.Prefixes.Add($"http://localhost:{CallbackPort}/"); + try + { + listener.Start(); + } + catch (HttpListenerException ex) + { + throw new InvalidOperationException( + $"Could not bind OAuth callback on port {CallbackPort}. Close anything using that port or paste the redirect URL manually. ({ex.Message})", + ex); + } + + try + { + Task getContext = listener.GetContextAsync(); + await using (cancellationToken.Register(static state => ((HttpListener)state!).Stop(), listener)) + { + HttpListenerContext context = await getContext.ConfigureAwait(false); + return await HandleCallbackAsync(context, session, cancellationToken).ConfigureAwait(false); + } + } + catch (HttpListenerException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + finally + { + if (listener.IsListening) + { + listener.Stop(); + } + } + } + + /// + /// Parses a pasted authorization code or full redirect URL into a callback + /// result, validating state when present. + /// + public static OpenAiCodexCallbackResult ParseAuthorizationInput(string input, OpenAiCodexPkceSession session) + { + string value = input.Trim(); + if (value.Length == 0) + { + throw new InvalidOperationException("Empty authorization input."); + } + + string? code = null; + string? state = null; + + if (Uri.TryCreate(value, UriKind.Absolute, out Uri? url)) + { + Dictionary query = ParseQuery(url.Query); + query.TryGetValue("code", out code); + query.TryGetValue("state", out state); + } + else if (value.Contains('#')) + { + string[] parts = value.Split('#', 2); + code = parts[0]; + state = parts.Length > 1 ? parts[1] : null; + } + else if (value.Contains("code=", StringComparison.Ordinal)) + { + Dictionary query = ParseQuery(value); + query.TryGetValue("code", out code); + query.TryGetValue("state", out state); + } + else + { + code = value; + state = session.State; + } + + if (string.IsNullOrEmpty(code)) + { + throw new InvalidOperationException("Could not find an authorization code in the pasted input."); + } + + if (!string.IsNullOrEmpty(state) && + !string.Equals(state, session.State, StringComparison.Ordinal)) + { + throw new InvalidOperationException("OAuth state mismatch."); + } + + return new OpenAiCodexCallbackResult(code, state ?? session.State); + } + + /// + /// Exchanges an authorization code for access + refresh tokens and extracts + /// the ChatGPT account id from the access JWT. + /// + public async ValueTask ExchangeCodeAsync( + OpenAiCodexPkceSession session, + OpenAiCodexCallbackResult callback, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(HttpMethod.Post, TokenUrl); + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = ClientId, + ["code"] = callback.Code, + ["code_verifier"] = session.Verifier, + ["redirect_uri"] = RedirectUri, + }); + + OpenAiCodexTokenResponse token = await SendAsync( + request, + OpenAiCodexJsonContext.Default.OpenAiCodexTokenResponse, + cancellationToken).ConfigureAwait(false); + + return ToTokenSet(token); + } + + /// + public async ValueTask RefreshAsync(OAuthTokenSet current, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(current.RefreshToken)) + { + throw new InvalidOperationException( + "No OpenAI Codex refresh token stored; run 'winharness login --provider openai' again."); + } + + using HttpRequestMessage request = new(HttpMethod.Post, TokenUrl); + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = current.RefreshToken, + ["client_id"] = ClientId, + }); + + OpenAiCodexTokenResponse token = await SendAsync( + request, + OpenAiCodexJsonContext.Default.OpenAiCodexTokenResponse, + cancellationToken).ConfigureAwait(false); + + OAuthTokenSet refreshed = ToTokenSet(token, requireRefreshToken: false, fallbackAccountId: current.AccountId); + if (string.IsNullOrEmpty(refreshed.RefreshToken)) + { + refreshed = refreshed with { RefreshToken = current.RefreshToken }; + } + + if (string.IsNullOrEmpty(refreshed.AccountId)) + { + refreshed = refreshed with { AccountId = current.AccountId }; + } + + return refreshed; + } + + /// Creates the static Codex model seed used on first login. + public static IReadOnlyList DefaultModels { get; } = + [ + new("gpt-5.4", "gpt-5.4", 272_000, Reasoning: true), + new("gpt-5.4-mini", "gpt-5.4-mini", 272_000, Reasoning: true), + new("gpt-5.5", "gpt-5.5", 272_000, Reasoning: true), + ]; + + /// + /// Extracts chatgpt_account_id from an access JWT. Returns null when + /// the claim is missing or the token is not a JWT. + /// + public static string? ExtractAccountId(string accessToken) + { + try + { + string[] parts = accessToken.Split('.'); + if (parts.Length != 3) + { + return null; + } + + string payload = parts[1]; + int pad = payload.Length % 4; + if (pad > 0) + { + payload += new string('=', 4 - pad); + } + + payload = payload.Replace('-', '+').Replace('_', '/'); + byte[] bytes = Convert.FromBase64String(payload); + using JsonDocument document = JsonDocument.Parse(bytes); + if (!document.RootElement.TryGetProperty(JwtAuthClaimPath, out JsonElement auth) || + !auth.TryGetProperty("chatgpt_account_id", out JsonElement accountId)) + { + return null; + } + + return accountId.GetString(); + } + catch (Exception) + { + return null; + } + } + + private static async ValueTask HandleCallbackAsync( + HttpListenerContext context, + OpenAiCodexPkceSession session, + CancellationToken cancellationToken) + { + Uri url = context.Request.Url ?? new Uri(RedirectUri); + Dictionary 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") + }; + } + } +}