Skip to content
Draft
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ Sign in with Vercel AI Gateway:
fx login
```

Or use the OpenAI Responses API, including a compatible HTTPS or local loopback endpoint:

```bash
export OPENAI_API_KEY=your_key
export OPENAI_BASE_URL=https://api.openai.com/v1
FX_MODEL=gpt-5.4 fx provider openai
fx
```

`OPENAI_BASE_URL` defaults to `https://api.openai.com/v1`. The OpenAI route accepts HTTPS endpoints and loopback HTTP endpoints, uses `OPENAI_API_KEY` only for that route, and does not send Vercel or subscription credentials to the configured endpoint. Configure `models.openai` in `~/.fx/settings.json` instead of `FX_MODEL` when you want to select the model persistently without the command above. OpenAI-compatible model discovery is not assumed; `fx models` reports that limitation for this provider.

Or use an eligible ChatGPT subscription through OpenAI Codex OAuth:

```bash
Expand All @@ -47,7 +58,7 @@ fx login grok
fx
```

`fx login codex` and `fx login grok` select that provider and a model from its authenticated catalog. Inside fx, open `/setup` and choose **Model provider** to move between Gateway, Codex, and Grok. `/model` lists the active provider's fetched models. Subscription model IDs are the raw IDs returned by each authenticated catalog. Use `/logout codex` or `/logout grok` to remove that subscription session without affecting other providers; choosing it again from **Model provider** starts sign-in.
`fx login codex` and `fx login grok` select that provider and a model from its authenticated catalog. Inside fx, open `/setup` and choose **Model provider** to move between Gateway, OpenAI, Codex, and Grok. `/model` lists the active provider's fetched models. Subscription model IDs are the raw IDs returned by each authenticated catalog. Use `/logout codex` or `/logout grok` to remove that subscription session without affecting other providers; choosing it again from **Model provider** starts sign-in.

The OpenAI Codex route uses ChatGPT subscription access directly and never sends its OAuth token to Vercel AI Gateway. The session is stored privately at `~/.fx/chatgpt-auth.json` and refreshed when needed. On supported Codex models, `/fast` requests OpenAI's priority service tier and consumes ChatGPT credits at the higher Fast mode rate.

Expand Down
6 changes: 6 additions & 0 deletions src/builtins/providers.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const provider_set = @import("../core/gateway/provider_set.zig");
const gateway = @import("gateway.zig");
const openai = @import("../gateway/openai.zig");
const openai_codex = @import("../gateway/openai_codex.zig");
const openai_codex_models = @import("../gateway/openai_codex_models.zig");
const openai_codex_permission_reviewer = @import("../gateway/openai_codex_permission_reviewer.zig");
Expand All @@ -10,6 +11,11 @@ const provider_catalog = @import("../core/auth/provider_catalog.zig");

pub const native = provider_set.Set{
.gateway = gateway.provider_bundle,
.openai = .{
.presentation = provider_catalog.find(.openai),
.fallback_model_capabilities_fn = openai.fallback_capabilities,
.agent_stream = openai.agent_stream_provider,
},
.codex = .{
.presentation = provider_catalog.find(.codex),
.auth_strategy = .chatgpt,
Expand Down
118 changes: 85 additions & 33 deletions src/core/app/app_auth_runtime.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const config_runtime = @import("../config/config_runtime.zig");
const settings_store = @import("../config/settings_store.zig");
const debug_trace = @import("../shared/debug_trace.zig");
const host = @import("../hosts/host.zig");
const runtime_profile = @import("../hosts/runtime_profile.zig");
Expand Down Expand Up @@ -57,6 +58,7 @@ pub fn Runtime(comptime App: type) type {
{
const provider = provider_runtime.provider(app);
const required_source: credentials.Source = switch (provider) {
.openai => .openai_api_key,
.codex => .chatgpt_subscription,
.grok => .grok_subscription,
.gateway => app.auth.credentialSource() orelse .fx_login,
Expand All @@ -71,7 +73,9 @@ pub fn Runtime(comptime App: type) type {
try app.writeDomainNotice(.{
.topic = "auth",
.tone = .warning,
.body = if (provider == .grok)
.body = if (provider == .openai)
credentials.missing_openai_interactive_credential_message
else if (provider == .grok)
credentials.missing_grok_interactive_credential_message
else if (provider == .codex)
credentials.missing_chatgpt_interactive_credential_message
Expand Down Expand Up @@ -133,7 +137,7 @@ pub fn Runtime(comptime App: type) type {
try writeAuthNotice(app, .{
.topic = "auth",
.tone = .warning,
.body = "Usage: /logout [vercel|codex|grok]",
.body = "Usage: /logout [vercel|openai|codex|grok]",
});
return;
};
Expand All @@ -152,6 +156,14 @@ pub fn Runtime(comptime App: type) type {
.active_source = app.auth.credentialSource(),
.available_sources = provider_inventory,
});
if (logout_provider == .openai) {
try writeAuthNotice(app, .{
.topic = "auth",
.tone = .neutral,
.body = "OpenAI uses the process-owned OPENAI_API_KEY. Unset it and restart fx to disconnect.",
});
return;
}
if (logout_provider == .grok) {
const outcome = grok_oauth.logout(app.alloc, app.auth.oauthTransport()) catch {
try writeAuthNotice(app, .{
Expand Down Expand Up @@ -805,6 +817,8 @@ pub fn Runtime(comptime App: type) type {
.tone = .warning,
.body = if (intent == .post_oauth)
"Subscription sign-in completed, but its saved credential is unavailable. The current provider is unchanged."
else if (target == .openai)
credentials.missing_openai_interactive_credential_message
else if (target == .codex)
"Run fx login codex, then try switching again."
else if (target == .grok)
Expand All @@ -828,40 +842,91 @@ pub fn Runtime(comptime App: type) type {
return;
}

const access = credentials.catalogAccessForCredentialAndAccount(
credential.source,
credential.token,
credential.gatewayTeam(),
credential.accountId(),
);
const fetched = app.fetchProviderCatalog(target, access) catch |err| {
debug_trace.logf("provider", "catalog preparation failed provider={t} err={s}", .{ target, @errorName(err) });
var settings = config_runtime.loadMergedSettings(app.alloc, app.workspace_root) catch |err| {
debug_trace.logf("provider", "settings load failed err={s}", .{@errorName(err)});
try app.writeDomainNotice(.{
.topic = "provider",
.tone = .@"error",
.body = providerFailureMessage(
intent,
"Could not load the target provider catalog. The current provider is unchanged.",
"Subscription sign-in completed, but its model catalog could not be loaded. The current provider is unchanged.",
"Could not load the saved provider model. The current provider is unchanged.",
"Subscription sign-in completed, but its saved provider model could not be loaded. The current provider is unchanged.",
),
}, true);
return;
};
var catalog = switch (fetched) {
.catalog => |catalog| catalog,
.failure => |failure| {
debug_trace.logf("provider", "catalog rejected provider={t} category={t}", .{ target, failure.category });
defer settings.deinit(app.alloc);

const access = credentials.catalogAccessForCredentialAndAccount(
credential.source,
credential.token,
credential.gatewayTeam(),
credential.accountId(),
);
var catalog = if (target == .openai) catalog: {
const model = io_mod.getenv("FX_MODEL") orelse settings.models.get(.openai) orelse {
try app.writeDomainNotice(.{
.topic = "provider",
.tone = .warning,
.body = "Configure models.openai or FX_MODEL before selecting OpenAI.",
}, true);
return;
};
settings_store.validateModel(model) catch {
try app.writeDomainNotice(.{
.topic = "provider",
.tone = .warning,
.body = "The configured OpenAI model is invalid. The current provider is unchanged.",
}, true);
return;
};
var configured: std.ArrayList(model_catalog.ModelCatalogEntry) = .empty;
errdefer model_catalog.freeModelCatalog(app.alloc, &configured);
const id = try app.alloc.dupe(u8, model);
const model_type = app.alloc.dupe(u8, "language") catch |err| {
app.alloc.free(id);
return err;
};
configured.append(app.alloc, .{
.id = id,
.model_type = model_type,
.has_tool_use = true,
}) catch |err| {
app.alloc.free(id);
app.alloc.free(model_type);
return err;
};
break :catalog configured;
} else catalog: {
const fetched = app.fetchProviderCatalog(target, access) catch |err| {
debug_trace.logf("provider", "catalog preparation failed provider={t} err={s}", .{ target, @errorName(err) });
try app.writeDomainNotice(.{
.topic = "provider",
.tone = .@"error",
.body = providerFailureMessage(
intent,
"The target provider catalog could not be validated. The current provider is unchanged.",
"Subscription sign-in completed, but its model catalog could not be validated. The current provider is unchanged.",
"Could not load the target provider catalog. The current provider is unchanged.",
"Subscription sign-in completed, but its model catalog could not be loaded. The current provider is unchanged.",
),
}, true);
return;
},
};
break :catalog switch (fetched) {
.catalog => |loaded| loaded,
.failure => |failure| {
debug_trace.logf("provider", "catalog rejected provider={t} category={t}", .{ target, failure.category });
try app.writeDomainNotice(.{
.topic = "provider",
.tone = .@"error",
.body = providerFailureMessage(
intent,
"The target provider catalog could not be validated. The current provider is unchanged.",
"Subscription sign-in completed, but its model catalog could not be validated. The current provider is unchanged.",
),
}, true);
return;
},
};
};
defer model_catalog.freeModelCatalog(app.alloc, &catalog);
if (catalog.items.len == 0) {
Expand All @@ -877,20 +942,6 @@ pub fn Runtime(comptime App: type) type {
return;
}

var settings = config_runtime.loadMergedSettings(app.alloc, app.workspace_root) catch |err| {
debug_trace.logf("provider", "settings load failed err={s}", .{@errorName(err)});
try app.writeDomainNotice(.{
.topic = "provider",
.tone = .@"error",
.body = providerFailureMessage(
intent,
"Could not load the saved provider model. The current provider is unchanged.",
"Subscription sign-in completed, but its saved provider model could not be loaded. The current provider is unchanged.",
),
}, true);
return;
};
defer settings.deinit(app.alloc);
const saved_model = settings.models.get(target);
const current_model = if (intent == .post_oauth and current == target)
provider_runtime.model(app)
Expand Down Expand Up @@ -1395,6 +1446,7 @@ test "interactive subscription sign-in rejects active and queued work before OAu
app.worker.queued_prompts = case.queued_prompts;

switch (provider) {
.openai => unreachable,
.codex => try Runtime(BusySignInApp).beginChatGptSignIn(&app),
.grok => try Runtime(BusySignInApp).beginGrokSignIn(&app),
.gateway => unreachable,
Expand Down
1 change: 1 addition & 0 deletions src/core/app/app_lifecycle.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,7 @@ fn configuredProviderSelection(
const provider = settings.provider orelse .gateway;
const model = settings.models.get(provider) orelse switch (provider) {
.gateway => default_model,
.openai => return error.OpenAIModelNotSelected,
.codex => return error.CodexModelNotSelected,
.grok => return error.GrokModelNotSelected,
};
Expand Down
23 changes: 20 additions & 3 deletions src/core/auth/auth_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,12 @@ pub const StatusSnapshot = struct {
pub fn missingHelp(self: StatusSnapshot, surface: MissingHelpSurface) ?[]const u8 {
if (self.active_source != null) return null;
if (self.stored_key_status == .unavailable) return credentials.unreadable_store_message;
if (self.required_source == .openai_api_key) {
return switch (surface) {
.cli => credentials.missing_openai_credential_message,
.interactive => credentials.missing_openai_interactive_credential_message,
};
}
if (self.required_source == .chatgpt_subscription) {
return switch (surface) {
.cli => credentials.missing_chatgpt_credential_message,
Expand Down Expand Up @@ -695,7 +701,7 @@ pub fn loadStatusSnapshotForProvider(
},
};
const resolved_source = if (resolution.credential) |credential| credential.source else null;
var gateway_connected = resolved_source != null and resolved_source != .chatgpt_subscription and resolved_source != .grok_subscription;
var gateway_connected = resolved_source != null and resolved_source != .openai_api_key and resolved_source != .chatgpt_subscription and resolved_source != .grok_subscription;
const gateway_probe_required = provider == .codex or provider == .grok or
resolved_source == .chatgpt_subscription or resolved_source == .grok_subscription;
if (gateway_probe_required) {
Expand Down Expand Up @@ -726,7 +732,9 @@ pub fn loadStatusSnapshotForProvider(
};
}
return .{
.required_source = if (provider == .codex)
.required_source = if (provider == .openai)
.openai_api_key
else if (provider == .codex)
.chatgpt_subscription
else if (provider == .grok)
.grok_subscription
Expand Down Expand Up @@ -1618,6 +1626,15 @@ pub const Runtime = struct {
provider: model_provider.ProviderId,
) !?bool {
return switch (provider) {
.openai => if (self.credentialSource() == .openai_api_key)
false
else
self.selectSourceWithLoader(
alloc,
.openai_api_key,
self,
loadRuntimeCredentialSource,
),
.codex => if (self.credentialSource() == .chatgpt_subscription)
false
else
Expand All @@ -1636,7 +1653,7 @@ pub const Runtime = struct {
self,
loadRuntimeCredentialSource,
),
.gateway => if (self.credentialSource() != .chatgpt_subscription and self.credentialSource() != .grok_subscription)
.gateway => if (self.credentialSource() != .openai_api_key and self.credentialSource() != .chatgpt_subscription and self.credentialSource() != .grok_subscription)
false
else
@as(?bool, try self.reselectByPrecedenceWithDeps(
Expand Down
8 changes: 8 additions & 0 deletions src/core/auth/auth_transition.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub const LogoutFacts = struct {

pub fn decideLogoutProvider(facts: LogoutFacts) model_provider.ProviderId {
if (facts.requested) |provider| return provider;
if (facts.selected == .openai or facts.active_source == .openai_api_key) return .openai;
if (facts.selected == .grok or facts.active_source == .grok_subscription) return .grok;
if (facts.selected == .codex or facts.active_source == .chatgpt_subscription) return .codex;

Expand All @@ -65,6 +66,7 @@ pub fn signInCompletion(
) SignInCompletionAction {
return switch (provider) {
.gateway => .vercel,
.openai => .{ .activate_source = .openai_api_key },
.codex => if (provider_routing_supported)
.{ .switch_provider = .codex }
else
Expand Down Expand Up @@ -108,6 +110,12 @@ test "provider switch and logout decisions are pure and provider keyed" {
.active_source = .chatgpt_subscription,
.available_sources = inventory,
}));
try std.testing.expectEqual(model_provider.ProviderId.openai, decideLogoutProvider(.{
.requested = null,
.selected = .openai,
.active_source = .openai_api_key,
.available_sources = inventory,
}));
}

test "sign in completion selects routing or credential activation without effects" {
Expand Down
1 change: 1 addition & 0 deletions src/core/auth/credential_authority.zig
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub fn derive(
switch (source) {
.vercel_oidc_token,
.ai_gateway_api_key,
.openai_api_key,
.fx_login,
.stored_key,
=> hash.update("\x00slot\x00"),
Expand Down
Loading
Loading