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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/design/pi-parity-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,14 @@ public sealed class ProviderOptions
public string Id { get; set; } = string.Empty;

/// <summary>
/// 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".
/// </summary>
public string Kind { get; set; } = string.Empty;

/// <summary>
/// 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).
/// </summary>
public string? BaseUrl { get; set; }

Expand Down
86 changes: 85 additions & 1 deletion src/WinHarness.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ICredentialStore>();
Expand Down Expand Up @@ -1054,6 +1061,83 @@ static async Task<AnthropicCallbackResult> WaitForAnthropicCallbackOrPasteAsync(
}


static async Task LoginOpenAiCodexAsync(IServiceProvider services, CancellationToken cancellationToken)
{
ICredentialStore store = services.GetRequiredService<ICredentialStore>();
ConfigStore configStore = services.GetRequiredService<ConfigStore>();
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<string>("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<ICredentialStore>();
Expand Down
Loading
Loading