Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/builtins/providers.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const openai_codex_permission_reviewer = @import("../gateway/openai_codex_permis
const xai_grok = @import("../gateway/xai_grok.zig");
const xai_grok_models = @import("../gateway/xai_grok_models.zig");
const xai_grok_permission_reviewer = @import("../gateway/xai_grok_permission_reviewer.zig");
const orcarouter = @import("../gateway/orcarouter.zig");
const orcarouter_models = @import("../gateway/orcarouter_models.zig");
const provider_catalog = @import("../core/auth/provider_catalog.zig");

pub const native = provider_set.Set{
Expand All @@ -26,4 +28,10 @@ pub const native = provider_set.Set{
.model_catalog = xai_grok_models.model_catalog_provider,
.permission_reviewer = xai_grok_permission_reviewer.provider,
},
.orcarouter = .{
.presentation = provider_catalog.find(.orcarouter),
.agent_stream = orcarouter.agent_stream_provider,
.cli_model_catalog = orcarouter_models.cli_model_catalog_provider,
.model_catalog = orcarouter_models.model_catalog_provider,
},
};
5 changes: 5 additions & 0 deletions src/core/app/app_agent_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,11 @@ pub fn Runtime(comptime App: type) type {
.agent_stream = tool_context.agent_stream_provider,
.permission_reviewer = tool_context.permission_reviewer_provider,
},
.orcarouter = .{
.capabilities = tool_context.provider_capabilities,
.agent_stream = tool_context.agent_stream_provider,
.permission_reviewer = tool_context.permission_reviewer_provider,
},
};
return subagent_agent_adapter.run(.{
.host = app_session_runtime.Runtime(App).subagentHost(app) orelse
Expand Down
4 changes: 4 additions & 0 deletions src/core/app/app_auth_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub fn Runtime(comptime App: type) type {
const required_source: credentials.Source = switch (provider) {
.codex => .chatgpt_subscription,
.grok => .grok_subscription,
.orcarouter => .orcarouter_api_key,
.gateway => app.auth.credentialSource() orelse .fx_login,
};
const route_change = app.auth.selectForProvider(app.alloc, provider) catch |err| switch (err) {
Expand All @@ -75,6 +76,8 @@ pub fn Runtime(comptime App: type) type {
credentials.missing_grok_interactive_credential_message
else if (provider == .codex)
credentials.missing_chatgpt_interactive_credential_message
else if (provider == .orcarouter)
credentials.missing_orcarouter_interactive_credential_message
else
credentials.missing_interactive_credential_message,
}, true);
Expand Down Expand Up @@ -1398,6 +1401,7 @@ test "interactive subscription sign-in rejects active and queued work before OAu
.codex => try Runtime(BusySignInApp).beginChatGptSignIn(&app),
.grok => try Runtime(BusySignInApp).beginGrokSignIn(&app),
.gateway => unreachable,
.orcarouter => unreachable,
}

try std.testing.expectEqual(@as(usize, 0), app.auth.start_count);
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 @@ -1115,6 +1115,7 @@ fn configuredProviderSelection(
.gateway => default_model,
.codex => return error.CodexModelNotSelected,
.grok => return error.GrokModelNotSelected,
.orcarouter => return error.OrcaRouterModelNotSelected,
};
return .{ .provider = provider, .model = model };
}
Expand Down
11 changes: 10 additions & 1 deletion src/core/auth/auth_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1636,7 +1636,16 @@ pub const Runtime = struct {
self,
loadRuntimeCredentialSource,
),
.gateway => if (self.credentialSource() != .chatgpt_subscription and self.credentialSource() != .grok_subscription)
.orcarouter => if (self.credentialSource() == .orcarouter_api_key)
false
else
self.selectSourceWithLoader(
alloc,
.orcarouter_api_key,
self,
loadRuntimeCredentialSource,
),
.gateway => if (self.credentialSource() != .chatgpt_subscription and self.credentialSource() != .grok_subscription and self.credentialSource() != .orcarouter_api_key)
false
else
@as(?bool, try self.reselectByPrecedenceWithDeps(
Expand Down
1 change: 1 addition & 0 deletions src/core/auth/auth_transition.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub fn signInCompletion(
.{ .switch_provider = .grok }
else
.{ .activate_source = .grok_subscription },
.orcarouter => .vercel,
};
}

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 @@ -25,6 +25,7 @@ pub fn derive(
.ai_gateway_api_key,
.fx_login,
.stored_key,
.orcarouter_api_key,
=> hash.update("\x00slot\x00"),
.chatgpt_subscription,
.grok_subscription,
Expand Down
14 changes: 13 additions & 1 deletion src/core/auth/credentials.zig
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub const CatalogAuthenticatedSource = enum {
stored_key,
chatgpt_subscription,
grok_subscription,
orcarouter_api_key,

fn credentialSource(self: CatalogAuthenticatedSource) Source {
return switch (self) {
Expand All @@ -53,6 +54,7 @@ pub const CatalogAuthenticatedSource = enum {
.stored_key => .stored_key,
.chatgpt_subscription => .chatgpt_subscription,
.grok_subscription => .grok_subscription,
.orcarouter_api_key => .orcarouter_api_key,
};
}
};
Expand Down Expand Up @@ -168,6 +170,7 @@ pub fn catalogAccessForCredentialAndAccount(
.stored_key => .stored_key,
.chatgpt_subscription => .chatgpt_subscription,
.grok_subscription => .grok_subscription,
.orcarouter_api_key => .orcarouter_api_key,
.fx_login => blk: {
const team = team_context orelse
return .{ .public_only = .fx_login_team_required };
Expand All @@ -180,7 +183,7 @@ pub fn catalogAccessForCredentialAndAccount(
.authenticated = .{
.source = authenticated_source,
.credential = credential,
.team_context = if (authenticated_source == .chatgpt_subscription or authenticated_source == .grok_subscription) null else team_context,
.team_context = if (authenticated_source == .chatgpt_subscription or authenticated_source == .grok_subscription or authenticated_source == .orcarouter_api_key) null else team_context,
.account_id = if (authenticated_source == .grok_subscription) account_id else null,
},
};
Expand All @@ -202,6 +205,8 @@ pub const missing_chatgpt_credential_message = "fx needs a Codex subscription lo
pub const missing_chatgpt_interactive_credential_message = "Codex needs a subscription login. Run /login, open Connections, then choose Codex subscription.";
pub const missing_grok_credential_message = "fx needs a Grok subscription login for this model. Run fx login grok.";
pub const missing_grok_interactive_credential_message = "Grok needs a subscription login. Run /login, open Connections, then choose Grok subscription.";
pub const missing_orcarouter_credential_message = "fx needs an OrcaRouter API key for this model. Set ORCAROUTER_API_KEY.";
pub const missing_orcarouter_interactive_credential_message = "OrcaRouter needs an API key. Set ORCAROUTER_API_KEY and choose OrcaRouter from Model provider.";
pub const unreadable_store_message = "fx could not read the stored API key from " ++ stored_key_backend_label ++ ". A key may be saved but unreadable. Set FX_TRACE_LOG for the failing step, or set AI_GATEWAY_API_KEY.";

test "public credential guidance spells fx lowercase" {
Expand Down Expand Up @@ -297,6 +302,10 @@ pub fn resolveForProvider(
};
return .{ .credential = credential };
},
.orcarouter => {
const credential = try loadEnvCredential(alloc, "ORCAROUTER_API_KEY", .orcarouter_api_key);
return .{ .credential = credential };
},
.gateway => {},
}
return resolvePreferring(
Expand Down Expand Up @@ -407,6 +416,7 @@ pub fn loadSource(
return switch (source) {
.vercel_oidc_token => loadEnvCredential(alloc, "VERCEL_OIDC_TOKEN", source),
.ai_gateway_api_key => loadEnvCredential(alloc, "AI_GATEWAY_API_KEY", source),
.orcarouter_api_key => loadEnvCredential(alloc, "ORCAROUTER_API_KEY", source),
.fx_login => loadFxLoginCredential(alloc, transport),
.stored_key => loadStoredKeyCredential(alloc, secret_store),
.chatgpt_subscription => loadChatGptCredential(alloc, transport, .if_needed),
Expand All @@ -422,6 +432,7 @@ pub fn sourceExists(
return switch (source) {
.vercel_oidc_token => nonEmptyEnvValue("VERCEL_OIDC_TOKEN") != null,
.ai_gateway_api_key => nonEmptyEnvValue("AI_GATEWAY_API_KEY") != null,
.orcarouter_api_key => nonEmptyEnvValue("ORCAROUTER_API_KEY") != null,
.fx_login => blk: {
const loaded = oauth_session.load(alloc) catch |err| switch (err) {
error.OutOfMemory => return err,
Expand Down Expand Up @@ -658,6 +669,7 @@ pub fn sourceLabel(source: Source) []const u8 {
return switch (source) {
.vercel_oidc_token => "VERCEL_OIDC_TOKEN",
.ai_gateway_api_key => "AI_GATEWAY_API_KEY",
.orcarouter_api_key => "ORCAROUTER_API_KEY",
.fx_login => "fx login",
.stored_key => "stored API key (" ++ stored_key_backend_label ++ ")",
.chatgpt_subscription => "Codex subscription",
Expand Down
12 changes: 12 additions & 0 deletions src/core/auth/provider_catalog.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ pub const entries = [_]Entry{
.description = "SuperGrok or X Premium subscription",
.subscription = true,
},
.{
.id = .orcarouter,
.slug = "orcarouter",
.aliases = &.{"orca"},
.name = "OrcaRouter",
.route_name = "OrcaRouter",
.description = "OrcaRouter API key",
.subscription = false,
},
};

pub fn parse(value: []const u8) ?model_provider.ProviderId {
Expand All @@ -61,9 +70,12 @@ test "auth provider catalog uses the model provider identity and explicit aliase
try std.testing.expectEqual(model_provider.ProviderId.gateway, parse("gateway").?);
try std.testing.expectEqual(model_provider.ProviderId.codex, parse("codex").?);
try std.testing.expectEqual(model_provider.ProviderId.grok, parse("grok").?);
try std.testing.expectEqual(model_provider.ProviderId.orcarouter, parse("orcarouter").?);
try std.testing.expectEqual(model_provider.ProviderId.orcarouter, parse("orca").?);
try std.testing.expect(parse("openai-codex") == null);
try std.testing.expect(parse("chatgpt") == null);
try std.testing.expect(parse("unknown") == null);
try std.testing.expect(find(.codex).subscription);
try std.testing.expect(find(.grok).subscription);
try std.testing.expect(!find(.orcarouter).subscription);
}
28 changes: 26 additions & 2 deletions src/core/cli/cli_surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ fn activateProviderSelection(
.gateway => "Gateway is already selected.\n",
.codex => "Codex is already selected.\n",
.grok => "Grok is already selected.\n",
.orcarouter => "OrcaRouter is already selected.\n",
});
return true;
}
Expand Down Expand Up @@ -749,6 +750,7 @@ fn activateProviderSelection(
switch (target) {
.codex => "Codex credential is unavailable",
.grok => "Grok credential is unavailable",
.orcarouter => "OrcaRouter credential is unavailable",
.gateway => "configure a Gateway credential first",
},
);
Expand All @@ -758,6 +760,7 @@ fn activateProviderSelection(
try writeProviderActivationError(alloc, deps, caller, switch (target) {
.codex => "Codex model catalog is unavailable",
.grok => "Grok model catalog is unavailable",
.orcarouter => "OrcaRouter model catalog is unavailable",
.gateway => "Gateway model catalog is unavailable",
});
return false;
Expand Down Expand Up @@ -804,12 +807,14 @@ fn activateProviderSelection(
.codex => try writeStdout(deps, "Signed in with Codex.\n"),
.grok => try writeStdout(deps, "Signed in with Grok.\n"),
.gateway => unreachable,
.orcarouter => unreachable,
};
if (caller == .provider_command) {
try writeStdout(deps, switch (target) {
.gateway => "Provider set to Gateway.\n",
.codex => "Provider set to Codex.\n",
.grok => "Provider set to Grok.\n",
.orcarouter => "Provider set to OrcaRouter.\n",
});
}
return true;
Expand Down Expand Up @@ -932,7 +937,7 @@ fn runNonInteractiveWithDeps(
.issue => |rest| return runGithubWorkflow(alloc, rest, cfg, global_args.modifiers, deps, .issue),
.login => |rest| {
const maybe_login_provider = parseLoginProvider(rest) catch {
try writeStderr(deps, "usage: fx login [vercel|codex|grok]\n");
try writeStderr(deps, "usage: fx login [vercel|codex|grok|orcarouter]\n");
return .handled_failure;
};
// Preserve the original `fx login` behavior for scripts and users.
Expand Down Expand Up @@ -986,16 +991,34 @@ fn runNonInteractiveWithDeps(
}
try writeStdout(deps, "Signed in with Grok.\n");
},
.orcarouter => {
// OrcaRouter authenticates with an API key from the
// environment; there is no OAuth session to create.
if (io_mod.getenv("ORCAROUTER_API_KEY") == null) {
try writeStderr(deps, "fx login: ORCAROUTER_API_KEY is not set\n");
return .handled_failure;
}
if (!try activateProviderSelection(alloc, cfg, deps, .orcarouter, .provider_login)) {
return .handled_failure;
}
try writeStdout(deps, "Using OrcaRouter with ORCAROUTER_API_KEY.\n");
},
}
return .handled_success;
},
.logout => |rest| {
const maybe_login_provider = parseLoginProvider(rest) catch {
try writeStderr(deps, "usage: fx logout [vercel|codex|grok]\n");
try writeStderr(deps, "usage: fx logout [vercel|codex|grok|orcarouter]\n");
return .handled_failure;
};
// Preserve the original `fx logout` behavior for scripts and users.
const login_provider = maybe_login_provider orelse .gateway;
if (login_provider == .orcarouter) {
// OrcaRouter has no stored session; the key lives in the
// environment until the user unsets it.
try writeStdout(deps, "OrcaRouter uses ORCAROUTER_API_KEY; there is no session to sign out of.\n");
return .handled_success;
}
if (login_provider == .codex) {
const outcome = chatgpt_oauth.logout() catch {
try writeStderr(deps, "fx logout: failed to durably remove saved Codex login\n");
Expand Down Expand Up @@ -1180,6 +1203,7 @@ fn runNonInteractiveWithDeps(
.gateway => "fx models: Gateway model catalog is unavailable\n",
.codex => "fx models: Codex model catalog is unavailable\n",
.grok => "fx models: Grok model catalog is unavailable\n",
.orcarouter => "fx models: OrcaRouter model catalog is unavailable\n",
});
return .handled_failure;
};
Expand Down
10 changes: 9 additions & 1 deletion src/core/config/model_provider.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub const ProviderId = enum {
gateway,
codex,
grok,
orcarouter,
};

pub const ProviderSelection = struct {
Expand All @@ -16,15 +17,17 @@ pub fn parse(value: []const u8) ?ProviderId {
if (std.ascii.eqlIgnoreCase(value, "gateway")) return .gateway;
if (std.ascii.eqlIgnoreCase(value, "codex")) return .codex;
if (std.ascii.eqlIgnoreCase(value, "grok")) return .grok;
if (std.ascii.eqlIgnoreCase(value, "orcarouter")) return .orcarouter;
return null;
}

pub fn authorizesCredential(provider: ProviderId, source: ?types.CredentialSource) bool {
const selected = source orelse return false;
return switch (provider) {
.gateway => selected != .chatgpt_subscription and selected != .grok_subscription,
.gateway => selected != .chatgpt_subscription and selected != .grok_subscription and selected != .orcarouter_api_key,
.codex => selected == .chatgpt_subscription,
.grok => selected == .grok_subscription,
.orcarouter => selected == .orcarouter_api_key,
};
}

Expand All @@ -38,12 +41,17 @@ test "explicit providers authorize only their own credential origins" {
try std.testing.expect(authorizesCredential(.grok, .grok_subscription));
try std.testing.expect(!authorizesCredential(.grok, .chatgpt_subscription));
try std.testing.expect(!authorizesCredential(.gateway, .grok_subscription));
try std.testing.expect(authorizesCredential(.orcarouter, .orcarouter_api_key));
try std.testing.expect(!authorizesCredential(.orcarouter, .ai_gateway_api_key));
try std.testing.expect(!authorizesCredential(.gateway, .orcarouter_api_key));
}

test "provider parsing exposes gateway codex and grok" {
try std.testing.expectEqual(ProviderId.gateway, parse("gateway").?);
try std.testing.expectEqual(ProviderId.codex, parse("CODEX").?);
try std.testing.expectEqual(ProviderId.grok, parse("GROK").?);
try std.testing.expectEqual(ProviderId.orcarouter, parse("orcarouter").?);
try std.testing.expectEqual(ProviderId.orcarouter, parse("OrcaRouter").?);
try std.testing.expect(parse("openai-codex") == null);
try std.testing.expect(parse("") == null);
}
1 change: 1 addition & 0 deletions src/core/config/settings_store.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,7 @@ fn putModelPreference(
.gateway => "model",
.codex => "codex_model",
.grok => "grok_model",
.orcarouter => "orcarouter_model",
};
if (root.contains(legacy_key)) {
_ = root.orderedRemove(legacy_key);
Expand Down
Loading