diff --git a/src/builtins/providers.zig b/src/builtins/providers.zig index ac63905b1..0d84e983a 100644 --- a/src/builtins/providers.zig +++ b/src/builtins/providers.zig @@ -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{ @@ -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, + }, }; diff --git a/src/core/app/app_agent_runtime.zig b/src/core/app/app_agent_runtime.zig index 02645d783..9e5b3f9fe 100644 --- a/src/core/app/app_agent_runtime.zig +++ b/src/core/app/app_agent_runtime.zig @@ -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 diff --git a/src/core/app/app_auth_runtime.zig b/src/core/app/app_auth_runtime.zig index 3cfa808ff..17899c1c2 100644 --- a/src/core/app/app_auth_runtime.zig +++ b/src/core/app/app_auth_runtime.zig @@ -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) { @@ -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); @@ -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); diff --git a/src/core/app/app_lifecycle.zig b/src/core/app/app_lifecycle.zig index 37ea7ef85..e776b7020 100644 --- a/src/core/app/app_lifecycle.zig +++ b/src/core/app/app_lifecycle.zig @@ -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 }; } diff --git a/src/core/auth/auth_runtime.zig b/src/core/auth/auth_runtime.zig index d96b188b8..58b345b64 100644 --- a/src/core/auth/auth_runtime.zig +++ b/src/core/auth/auth_runtime.zig @@ -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( diff --git a/src/core/auth/auth_transition.zig b/src/core/auth/auth_transition.zig index 84abed350..4269a95d3 100644 --- a/src/core/auth/auth_transition.zig +++ b/src/core/auth/auth_transition.zig @@ -73,6 +73,7 @@ pub fn signInCompletion( .{ .switch_provider = .grok } else .{ .activate_source = .grok_subscription }, + .orcarouter => .vercel, }; } diff --git a/src/core/auth/credential_authority.zig b/src/core/auth/credential_authority.zig index a031d3c42..ba356e4dc 100644 --- a/src/core/auth/credential_authority.zig +++ b/src/core/auth/credential_authority.zig @@ -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, diff --git a/src/core/auth/credentials.zig b/src/core/auth/credentials.zig index 563a11c04..86b47a18a 100644 --- a/src/core/auth/credentials.zig +++ b/src/core/auth/credentials.zig @@ -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) { @@ -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, }; } }; @@ -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 }; @@ -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, }, }; @@ -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" { @@ -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( @@ -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), @@ -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, @@ -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", diff --git a/src/core/auth/provider_catalog.zig b/src/core/auth/provider_catalog.zig index d949aa7ff..5cdb7ac37 100644 --- a/src/core/auth/provider_catalog.zig +++ b/src/core/auth/provider_catalog.zig @@ -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 { @@ -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); } diff --git a/src/core/cli/cli_surface.zig b/src/core/cli/cli_surface.zig index dc4f4f915..02235211b 100644 --- a/src/core/cli/cli_surface.zig +++ b/src/core/cli/cli_surface.zig @@ -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; } @@ -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", }, ); @@ -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; @@ -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; @@ -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. @@ -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"); @@ -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; }; diff --git a/src/core/config/model_provider.zig b/src/core/config/model_provider.zig index 93b0168a7..3d0d49ad9 100644 --- a/src/core/config/model_provider.zig +++ b/src/core/config/model_provider.zig @@ -5,6 +5,7 @@ pub const ProviderId = enum { gateway, codex, grok, + orcarouter, }; pub const ProviderSelection = struct { @@ -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, }; } @@ -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); } diff --git a/src/core/config/settings_store.zig b/src/core/config/settings_store.zig index 5cb8e756a..34279ff15 100644 --- a/src/core/config/settings_store.zig +++ b/src/core/config/settings_store.zig @@ -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); diff --git a/src/core/gateway/provider_set.zig b/src/core/gateway/provider_set.zig index 97d921acb..c0ea92e47 100644 --- a/src/core/gateway/provider_set.zig +++ b/src/core/gateway/provider_set.zig @@ -51,12 +51,14 @@ pub const Set = struct { gateway: Bundle, codex: Bundle, grok: Bundle, + orcarouter: Bundle, pub fn select(self: Set, provider: model_provider.ProviderId) Bundle { return switch (provider) { .gateway => self.gateway, .codex => self.codex, .grok => self.grok, + .orcarouter => self.orcarouter, }; } @@ -65,6 +67,7 @@ pub const Set = struct { .gateway = self.gateway.deferred_usage, .codex = self.codex.deferred_usage, .grok = self.grok.deferred_usage, + .orcarouter = self.orcarouter.deferred_usage, }; } }; @@ -74,6 +77,7 @@ pub fn gateway_only(gateway: Bundle) Set { .gateway = gateway, .codex = .{}, .grok = .{}, + .orcarouter = .{}, }; } @@ -144,7 +148,16 @@ test "provider set selects each provider's complete route" { .model_catalog = .{ .context = &grok_tag, .fetch_fn = Fake.model_catalog_fetch }, .permission_reviewer = .{ .context = &grok_tag, .review_fn = Fake.review }, }; - var providers = Set{ .gateway = gateway, .codex = codex, .grok = grok }; + const orcarouter = Bundle{ + .agent_stream = stream_provider.Provider{ + .context = &grok_tag, + .stream_fn = stream_provider.unavailable_provider.stream_fn, + }, + .cli_model_catalog = .{ .context = &grok_tag, .fetch_fn = Fake.cli_catalog }, + .model_catalog = .{ .context = &grok_tag, .fetch_fn = Fake.model_catalog_fetch }, + .permission_reviewer = .{ .context = &grok_tag, .review_fn = Fake.review }, + }; + var providers = Set{ .gateway = gateway, .codex = codex, .grok = grok, .orcarouter = orcarouter }; try std.testing.expect(providers.select(.gateway).agent_stream.?.context.? == @as(*anyopaque, @ptrCast(&gateway_tag))); try std.testing.expect(providers.select(.gateway).capabilities.fx_search); @@ -158,6 +171,7 @@ test "provider set selects each provider's complete route" { try std.testing.expect(providers.select(.codex).model_catalog.?.context.? == @as(*anyopaque, @ptrCast(&codex_tag))); try std.testing.expect(providers.select(.grok).permission_reviewer.?.context.? == @as(*anyopaque, @ptrCast(&grok_tag))); try std.testing.expect(providers.select(.codex).agent_stream_or_unavailable().context.? == @as(*anyopaque, @ptrCast(&codex_tag))); + try std.testing.expect(providers.select(.orcarouter).agent_stream_or_unavailable().context.? == @as(*anyopaque, @ptrCast(&grok_tag))); providers.codex.model_catalog = null; try std.testing.expect(providers.select(.codex).model_catalog == null); diff --git a/src/core/output/output_contracts.zig b/src/core/output/output_contracts.zig index 271346b91..2385d5881 100644 --- a/src/core/output/output_contracts.zig +++ b/src/core/output/output_contracts.zig @@ -852,6 +852,7 @@ pub const ModelListSnapshot = struct { .gateway => "gateway", .codex => provider_catalog.label(.codex), .grok => provider_catalog.label(.grok), + .orcarouter => provider_catalog.label(.orcarouter), }; } diff --git a/src/core/session/generation_usage_provider.zig b/src/core/session/generation_usage_provider.zig index 19ad021b1..7895792dd 100644 --- a/src/core/session/generation_usage_provider.zig +++ b/src/core/session/generation_usage_provider.zig @@ -85,6 +85,7 @@ pub const Set = struct { gateway: ?Provider = null, codex: ?Provider = null, grok: ?Provider = null, + orcarouter: ?Provider = null, pub fn gatewayOnly(provider: Provider) Set { return .{ .gateway = provider }; @@ -95,6 +96,7 @@ pub const Set = struct { .gateway => self.gateway, .codex => self.codex, .grok => self.grok, + .orcarouter => self.orcarouter, }; } }; diff --git a/src/core/session/session_usage.zig b/src/core/session/session_usage.zig index 1d354e746..d517d55f3 100644 --- a/src/core/session/session_usage.zig +++ b/src/core/session/session_usage.zig @@ -3272,6 +3272,7 @@ fn exactUsageOrigin(provider: model_provider.ProviderId) []const u8 { .gateway => "exact/gateway", .codex => "exact/codex", .grok => "exact/grok", + .orcarouter => "exact/orcarouter", }; } diff --git a/src/core/shared/types.zig b/src/core/shared/types.zig index 3901bc51d..3a8feeb92 100644 --- a/src/core/shared/types.zig +++ b/src/core/shared/types.zig @@ -94,6 +94,7 @@ pub const CredentialSource = enum { stored_key, chatgpt_subscription, grok_subscription, + orcarouter_api_key, }; pub fn parseCredentialSource(text: []const u8) ?CredentialSource { diff --git a/src/gateway/orcarouter.zig b/src/gateway/orcarouter.zig new file mode 100644 index 000000000..c0766a15c --- /dev/null +++ b/src/gateway/orcarouter.zig @@ -0,0 +1,611 @@ +const std = @import("std"); +const image_attachments = @import("../core/images/image_attachments.zig"); +const secret = @import("../core/auth/secret.zig"); +const stream_provider = @import("../core/agent/stream_provider.zig"); +const io_mod = @import("../core/shared/io.zig"); +const types = @import("../core/shared/types.zig"); +const gateway_client = @import("client.zig"); +const responses_protocol = @import("responses_protocol.zig"); +const model_tool_schema = @import("../core/tooling/model_tool_schema.zig"); + +const Allocator = std.mem.Allocator; +const endpoint = "https://api.orcarouter.ai/v1/responses"; +const e2e_endpoint_env = "FX_E2E_ORCAROUTER_RESPONSES_URL"; +const max_error_body_bytes: usize = 1024 * 1024; +const max_sse_line_bytes: usize = 32 * 1024 * 1024; +const max_sse_aggregate_bytes: usize = 64 * 1024 * 1024; +const max_sse_events: usize = 100_000; +const max_tool_calls: usize = 128; +const max_tool_identity_bytes: usize = 1024; +const max_tool_arguments_bytes: usize = 4 * 1024 * 1024; +const max_provider_state_bytes: usize = 4 * 1024 * 1024; +const transfer_buffer_bytes: usize = 256 * 1024; +const connect_timeout_ms: i64 = 30_000; + +const OrcaRouterLimits = struct { + aggregate_bytes: usize = max_sse_aggregate_bytes, + events: usize = max_sse_events, + tool_calls: usize = max_tool_calls, + tool_identity_bytes: usize = max_tool_identity_bytes, + tool_arguments_bytes: usize = max_tool_arguments_bytes, + provider_state_bytes: usize = max_provider_state_bytes, +}; + +pub const agent_stream_provider = stream_provider.Provider{ + .stream_fn = streamCompletion, +}; + +fn validateModel(model: []const u8) !void { + if (model.len == 0 or model.len > 1024) return error.InvalidOrcaRouterModel; + for (model) |byte| { + if (byte <= 0x20 or byte == 0x7f) return error.InvalidOrcaRouterModel; + } +} + +pub fn buildRequest( + alloc: Allocator, + request: stream_provider.RequestData, +) ![]u8 { + try validateModel(request.model); + if (request.budget) |budget| { + if (budget.cancel_flag) |flag| if (flag.load(.seq_cst)) return error.Cancelled; + _ = budget.deadline; + } + + var instructions: std.Io.Writer.Allocating = .init(alloc); + defer instructions.deinit(); + for (request.messages) |message| { + if (message.role != .system) continue; + const text = message.content orelse continue; + if (text.len == 0) continue; + if (instructions.written().len > 0) try instructions.writer.writeAll("\n\n"); + try instructions.writer.writeAll(text); + } + if (instructions.written().len == 0) try instructions.writer.writeAll("You are a helpful assistant."); + + var out: std.Io.Writer.Allocating = .init(alloc); + errdefer out.deinit(); + const writer = &out.writer; + try writer.writeAll("{\"model\":"); + try std.json.Stringify.value(request.model, .{}, writer); + try writer.writeAll(",\"store\":false,\"stream\":true,\"instructions\":"); + try std.json.Stringify.value(instructions.written(), .{}, writer); + try writer.writeAll(",\"input\":["); + try writeResponsesInput(writer, alloc, request.messages, request.verified_images); + try writer.writeByte(']'); + + _ = try responses_protocol.writeTools(writer, alloc, request.tools); + try writer.writeAll(",\"tool_choice\":"); + try std.json.Stringify.value(request.tool_choice.label(), .{}, writer); + try writer.writeAll(",\"parallel_tool_calls\":true"); + if (request.max_output_tokens) |max_output_tokens| { + try writer.writeAll(",\"max_output_tokens\":"); + try std.json.Stringify.value(max_output_tokens, .{}, writer); + } + + try writer.writeAll(",\"text\":{\"verbosity\":\"low\""); + if (request.response_format) |format| { + if (format.schema != .object) return error.InvalidStructuredResponseSchema; + try writer.writeAll(",\"format\":{\"type\":\"json_schema\",\"name\":"); + try std.json.Stringify.value(format.name, .{}, writer); + try writer.writeAll(",\"description\":"); + try std.json.Stringify.value(format.description, .{}, writer); + try writer.writeAll(",\"schema\":"); + try std.json.Stringify.value(format.schema, .{}, writer); + try writer.writeAll(",\"strict\":true}"); + } + try writer.writeByte('}'); + + if (request.provider_options.reasoning) |effort| { + try writer.writeAll(",\"reasoning\":{\"effort\":"); + try std.json.Stringify.value(effort.label(), .{}, writer); + try writer.writeByte('}'); + } + try writer.writeByte('}'); + return out.toOwnedSlice(); +} + +fn writeResponsesInput( + writer: *std.Io.Writer, + alloc: Allocator, + messages: []const types.ChatMessage, + images: ?[]const image_attachments.VerifiedSnapshot, +) !void { + return responses_protocol.writeInput(writer, alloc, messages, images, .{ + .tool_calls = max_tool_calls, + .tool_identity_bytes = max_tool_identity_bytes, + .tool_arguments_bytes = max_tool_arguments_bytes, + .provider_state_bytes = max_provider_state_bytes, + }) catch |err| switch (err) { + error.ProviderStateTooLarge => error.OrcaRouterProviderStateTooLarge, + error.InvalidProviderState => error.InvalidOrcaRouterProviderState, + error.ToolCallLimitExceeded => error.OrcaRouterToolCallLimitExceeded, + error.ToolArgumentsTooLarge => error.OrcaRouterToolArgumentsTooLarge, + else => err, + }; +} + +fn streamCompletion( + _: ?*anyopaque, + alloc: Allocator, + request: stream_provider.ModelRequest, +) !stream_provider.Result { + if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); + if (request.credential.source != .orcarouter_api_key) { + return stream_provider.failResult(error.OrcaRouterApiKeyCredentialRequired); + } + try validateModel(request.model); + const payload = try buildRequest(alloc, request.data()); + defer alloc.free(payload); + return streamPrepared(alloc, request, payload) catch |err| { + if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); + request.attempt_evidence.network_failure = gateway_client.networkFailureEvidence(err, request.delivery.load()); + return err; + }; +} + +const OpenedRequest = struct { + request: ?std.http.Client.Request, + + pub fn deinit(self: *OpenedRequest, _: Allocator) void { + if (self.request) |*request| request.deinit(); + self.request = null; + } + + pub fn take(self: *OpenedRequest) std.http.Client.Request { + const request = self.request.?; + self.request = null; + return request; + } +}; + +const OpenRequestOperation = struct { + client: *std.http.Client, + uri: std.Uri, + auth_header: []const u8, + + pub fn run(self: *@This()) !OpenedRequest { + return .{ .request = try self.client.request(.POST, self.uri, .{ + .headers = .{ + .content_type = .{ .override = "application/json" }, + .authorization = .{ .override = self.auth_header }, + .accept_encoding = .omit, + .user_agent = .{ .override = gateway_client.user_agent }, + }, + .extra_headers = &.{ + .{ .name = "accept", .value = "text/event-stream" }, + }, + .keep_alive = false, + .redirect_behavior = .unhandled, + }) }; + } +}; + +pub fn streamPrepared( + alloc: Allocator, + request: stream_provider.ModelRequest, + payload: []const u8, +) !stream_provider.Result { + if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); + const auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.credential.secret}); + defer secret.zeroAndFree(alloc, auth_header); + const request_endpoint = if (io_mod.getenv(e2e_endpoint_env)) |override| endpoint: { + if (!gateway_client.isLoopbackHttpUrl(override)) { + return stream_provider.failResult(error.InvalidE2EOrcaRouterEndpoint); + } + break :endpoint override; + } else endpoint; + const uri = try std.Uri.parse(request_endpoint); + + var client: std.http.Client = .{ .allocator = alloc, .io = io_mod.getIo() }; + defer client.deinit(); + var open_operation = OpenRequestOperation{ + .client = &client, + .uri = uri, + .auth_header = auth_header, + }; + const connect_deadline = std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ + .clock = .awake, + .raw = .fromMilliseconds(connect_timeout_ms), + }); + try request.admission.admit(); + var opened = try gateway_client.runBoundedHttpOperation( + OpenedRequest, + alloc, + request.cancel_flag, + connect_deadline, + &open_operation, + ); + var http_request = opened.take(); + defer http_request.deinit(); + var cancel_watch_done = std.atomic.Value(bool).init(false); + const cancel_watcher = if (http_request.connection) |connection| + try gateway_client.spawnHttpCancelWatcher( + &cancel_watch_done, + request.cancel_flag, + connection.stream_writer.stream, + ) + else + null; + defer { + cancel_watch_done.store(true, .seq_cst); + if (cancel_watcher) |thread| thread.join(); + } + if (request.cancel_flag.load(.seq_cst)) return error.Cancelled; + + http_request.transfer_encoding = .{ .content_length = payload.len }; + var send_buffer: [8192]u8 = undefined; + request.delivery.markPossiblySent(); + var body_writer = try http_request.sendBodyUnflushed(&send_buffer); + try body_writer.writer.writeAll(payload); + try body_writer.end(); + if (http_request.connection) |connection| try connection.flush(); + if (request.cancel_flag.load(.seq_cst)) return error.Cancelled; + + var response = try http_request.receiveHead(&.{}); + if (response.head.status != .ok) { + var transfer: [16 * 1024]u8 = undefined; + const reader = response.reader(&transfer); + const body = reader.allocRemaining(alloc, .limited(max_error_body_bytes)) catch |err| switch (err) { + error.StreamTooLong => try alloc.dupe(u8, "OrcaRouter error response exceeded the local limit"), + else => return err, + }; + return .{ .failed = .{ + .kind = failureKind(response.head.status), + .detail = body, + .ownership = .owned, + } }; + } + + var transfer_buffer: [transfer_buffer_bytes]u8 = undefined; + const reader = response.reader(&transfer_buffer); + var events = request.events; + const completion = try consumeSse( + alloc, + reader, + &events, + EventBridge.content, + EventBridge.toolStart, + EventBridge.reasoning, + EventBridge.toolInput, + request.cancel_flag, + request.content_capture_limit, + .{}, + ); + errdefer { + var owned = stream_provider.Result{ .completed = .{ + .completion = completion, + .ownership = .owned, + } }; + owned.deinit(alloc); + } + const usage_outcome: stream_provider.UsageOutcome = usage: { + if (completion.generation_id == null) { + break :usage .{ .unavailable = .possibly_billed }; + } + break :usage .{ .deferred = .{ + .provider = .orcarouter, + .generation_id = completion.generation_id.?, + .scope = "https://api.orcarouter.ai", + .tenant = request.credential.tenant, + .credential_source = request.credential.source orelse .orcarouter_api_key, + .credential_identity = null, + } }; + }; + return .{ .completed = .{ + .completion = completion, + .usage = usage_outcome, + .ownership = .owned, + } }; +} + +const EventBridge = struct { + fn sink(raw: *anyopaque) *stream_provider.EventSink { + return @ptrCast(@alignCast(raw)); + } + + fn content(raw: *anyopaque, chunk: []const u8) void { + sink(raw).emit(.{ .content_delta = chunk }); + } + + fn reasoning(raw: *anyopaque, chunk: []const u8) void { + sink(raw).emit(.{ .reasoning_delta = chunk }); + } + + fn toolInput(raw: *anyopaque, chunk: []const u8) void { + sink(raw).emit(.{ .tool_input_delta = chunk }); + } + + fn toolStart(raw: *anyopaque, id: []const u8, name: []const u8, label: ?[]const u8) void { + sink(raw).emit(.{ .tool_started = .{ .id = id, .name = name, .label = label } }); + } +}; + +fn failureKind(status: std.http.Status) stream_provider.FailureKind { + return switch (status) { + .bad_request => .invalid_request, + .unauthorized => .unauthorized, + .forbidden => .forbidden, + .payload_too_large => .request_too_large, + .too_many_requests => .rate_limited, + .internal_server_error => .server_error, + .bad_gateway => .bad_gateway, + .service_unavailable => .unavailable, + .gateway_timeout => .gateway_timeout, + else => .provider_error, + }; +} + +const SseReader = struct { + pending_line: std.ArrayList(u8) = .empty, + + fn deinit(self: *SseReader, alloc: Allocator) void { + self.pending_line.deinit(alloc); + } + + fn release(self: *SseReader) void { + self.pending_line.clearRetainingCapacity(); + } + + fn next(self: *SseReader, alloc: Allocator, reader: anytype) !?[]const u8 { + while (true) { + const line = try self.readLine(alloc, reader) orelse return null; + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0 or trimmed[0] == ':') { + self.release(); + continue; + } + if (!std.mem.startsWith(u8, trimmed, "data:")) { + self.release(); + continue; + } + const data = std.mem.trim(u8, trimmed["data:".len..], " \t"); + if (std.mem.eql(u8, data, "[DONE]")) return null; + return data; + } + } + + fn readLine(self: *SseReader, alloc: Allocator, reader: anytype) !?[]const u8 { + while (true) { + const fragment = reader.takeDelimiter('\n') catch |err| switch (err) { + error.StreamTooLong => { + const buffered = reader.buffered(); + if (buffered.len == 0) return error.OrcaRouterSseReadStalled; + if (buffered.len > max_sse_line_bytes - self.pending_line.items.len) { + return error.OrcaRouterSseEventTooLarge; + } + try self.pending_line.appendSlice(alloc, buffered); + reader.tossBuffered(); + continue; + }, + error.ReadFailed => return error.ReadFailed, + } orelse { + if (self.pending_line.items.len > 0) return self.pending_line.items; + return null; + }; + if (fragment.len > max_sse_line_bytes - self.pending_line.items.len) { + return error.OrcaRouterSseEventTooLarge; + } + if (self.pending_line.items.len == 0) return fragment; + try self.pending_line.appendSlice(alloc, fragment); + return self.pending_line.items; + } + } +}; + +fn consumeSse( + alloc: Allocator, + reader: anytype, + callback_ctx: *anyopaque, + on_content_chunk: stream_provider.StreamCallback, + on_tool_start: ?stream_provider.ToolStartCallback, + on_reasoning_chunk: ?stream_provider.StreamCallback, + on_tool_input_chunk: ?stream_provider.StreamCallback, + cancel_flag: *std.atomic.Value(bool), + content_capture_limit: ?usize, + limits: OrcaRouterLimits, +) !types.ModelCompletion { + var reducer = responses_protocol.Reducer.init(alloc); + defer reducer.deinit(alloc); + var sse: SseReader = .{}; + defer sse.deinit(alloc); + const callbacks = responses_protocol.StreamCallbacks{ + .context = callback_ctx, + .on_content = on_content_chunk, + .on_tool_start = on_tool_start, + .on_reasoning = on_reasoning_chunk, + .on_tool_input = on_tool_input_chunk, + }; + const stream_limits = responses_protocol.StreamLimits{ + .aggregate_bytes = limits.aggregate_bytes, + .events = limits.events, + .tool_calls = limits.tool_calls, + .tool_identity_bytes = limits.tool_identity_bytes, + .tool_arguments_bytes = limits.tool_arguments_bytes, + .provider_state_bytes = limits.provider_state_bytes, + }; + while (try sse.next(alloc, reader)) |json_text| { + defer sse.release(); + if (reducer.applyJson( + alloc, + json_text, + callbacks, + cancel_flag, + content_capture_limit, + stream_limits, + ) catch |err| return mapReducerError(err)) break; + } + return reducer.finish(alloc, cancel_flag, stream_limits) catch |err| + return mapReducerError(err); +} + +fn mapReducerError(err: anyerror) anyerror { + return switch (err) { + error.InvalidEvent => error.InvalidOrcaRouterSseEvent, + error.ResponseFailed => error.OrcaRouterResponseFailed, + error.StreamIncomplete => error.OrcaRouterStreamIncomplete, + error.ToolCallLimitExceeded => error.OrcaRouterToolCallLimitExceeded, + error.ToolArgumentsTooLarge => error.OrcaRouterToolArgumentsTooLarge, + error.ResourceLimitExceeded => error.OrcaRouterResourceLimitExceeded, + else => err, + }; +} + +test "OrcaRouter request uses Responses input and converts AI SDK tool schemas" { + const read_file_schema = model_tool_schema.FunctionSchema{ + .name = "read_file", + .description = "Read", + .input_schema = .{}, + }; + const messages = [_]types.ChatMessage{ + .{ .role = .system, .content = "Be concise." }, + .{ .role = .user, .content = "Read it." }, + .{ + .role = .assistant, + .tool_calls = &.{.{ .id = "call_1", .name = "read_file", .arguments_json = "{\"path\":\"README.md\"}" }}, + .provider_state_json = "[{\"id\":\"rs_1\",\"type\":\"reasoning\",\"encrypted_content\":\"opaque\"}]", + }, + .{ .role = .tool, .tool_call_id = "call_1", .tool_name = "read_file", .content = "contents" }, + }; + const body = try buildRequest(std.testing.allocator, .{ + .model = "orcarouter/fusion-flash", + .messages = &messages, + .tools = .{ .additional_functions = &.{read_file_schema} }, + .tool_choice = .auto, + .provider_options = .{ .reasoning = types.ReasoningEffort.literal("high") }, + }); + defer std.testing.allocator.free(body); + + try std.testing.expect(std.mem.find(u8, body, "\"model\":\"orcarouter/fusion-flash\"") != null); + try std.testing.expect(std.mem.find(u8, body, "\"instructions\":\"Be concise.\"") != null); + try std.testing.expect(std.mem.find(u8, body, "\"type\":\"function_call_output\"") != null); + try std.testing.expect(std.mem.find(u8, body, "\"encrypted_content\":\"opaque\"") != null); + try std.testing.expect(std.mem.find(u8, body, "\"parameters\":{\"type\":\"object\",\"properties\":{}}") != null); + try std.testing.expect(std.mem.find(u8, body, "\"reasoning\":{\"effort\":\"high\"") != null); +} + +test "OrcaRouter request bounds max output tokens and omits empty tool choice" { + const messages = [_]types.ChatMessage{.{ .role = .user, .content = "Hello." }}; + const body = try buildRequest(std.testing.allocator, .{ + .model = "orcarouter/fusion", + .messages = &messages, + .tool_choice = .none, + .provider_options = .{}, + .max_output_tokens = 4096, + }); + defer std.testing.allocator.free(body); + + try std.testing.expect(std.mem.find(u8, body, "\"max_output_tokens\":4096") != null); +} + +fn consumeOrcaRouterTestSse(sse_text: []const u8, limits: OrcaRouterLimits) !types.ModelCompletion { + var reader: std.Io.Reader = .fixed(sse_text); + var cancelled = std.atomic.Value(bool).init(false); + var callback_context: u8 = 0; + return consumeSse( + std.testing.allocator, + &reader, + &callback_context, + struct { + fn ignore(_: *anyopaque, _: []const u8) void {} + }.ignore, + null, + null, + null, + &cancelled, + null, + limits, + ); +} + +fn freeOrcaRouterTestCompletion(completion: types.ModelCompletion) void { + if (completion.content) |value| std.testing.allocator.free(@constCast(value)); + types.freeToolCallSlice(std.testing.allocator, @constCast(completion.tool_calls)); + if (completion.generation_id) |value| std.testing.allocator.free(@constCast(value)); + if (completion.provider_state_json) |value| std.testing.allocator.free(@constCast(value)); +} + +test "OrcaRouter SSE maps text reasoning tools and usage" { + const sse_text = + "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\"}}\n\n" ++ + "data: {\"type\":\"response.reasoning_summary_text.delta\",\"output_index\":0,\"delta\":\"thinking\"}\n\n" ++ + "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"opaque\"}}\n\n" ++ + "data: {\"type\":\"response.output_item.added\",\"output_index\":1,\"item\":{\"type\":\"message\"}}\n\n" ++ + "data: {\"type\":\"response.output_text.delta\",\"output_index\":1,\"delta\":\"hello\"}\n\n" ++ + "data: {\"type\":\"response.output_item.added\",\"output_index\":2,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"read_file\"}}\n\n" ++ + "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":2,\"delta\":\"{\\\"path\\\":\\\"README.md\\\"}\"}\n\n" ++ + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":4}}}\n\n"; + var reader: std.Io.Reader = .fixed(sse_text); + var cancelled = std.atomic.Value(bool).init(false); + const Capture = struct { + content: std.ArrayList(u8) = .empty, + reasoning: std.ArrayList(u8) = .empty, + saw_read_file: bool = false, + + fn contentChunk(raw: *anyopaque, chunk: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.content.appendSlice(std.testing.allocator, chunk) catch unreachable; + } + fn reasoningChunk(raw: *anyopaque, chunk: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.reasoning.appendSlice(std.testing.allocator, chunk) catch unreachable; + } + fn toolStart(raw: *anyopaque, _: []const u8, name: []const u8, _: ?[]const u8) void { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.saw_read_file = std.mem.eql(u8, name, "read_file"); + } + }; + var capture: Capture = .{}; + defer capture.content.deinit(std.testing.allocator); + defer capture.reasoning.deinit(std.testing.allocator); + const completion = try consumeSse( + std.testing.allocator, + &reader, + &capture, + Capture.contentChunk, + Capture.toolStart, + Capture.reasoningChunk, + null, + &cancelled, + null, + .{}, + ); + defer freeOrcaRouterTestCompletion(completion); + try std.testing.expectEqualStrings("hello", capture.content.items); + try std.testing.expectEqualStrings("thinking", capture.reasoning.items); + try std.testing.expect(capture.saw_read_file); + try std.testing.expectEqual(@as(usize, 1), completion.tool_calls.len); + try std.testing.expectEqualStrings("call_1", completion.tool_calls[0].id); + try std.testing.expectEqualStrings("{\"path\":\"README.md\"}", completion.tool_calls[0].arguments_json); + try std.testing.expectEqual(@as(?u64, 10), completion.usage.input_tokens); + try std.testing.expect(completion.provider_state_json != null); + try std.testing.expect(std.mem.find(u8, completion.provider_state_json.?, "\"encrypted_content\":\"opaque\"") != null); + try std.testing.expectEqual(types.ProviderFinishReason.tool_calls, completion.finish_reason.?); +} + +test "OrcaRouter rejects a wrong-origin credential before network I/O" { + var cancelled = std.atomic.Value(bool).init(false); + var delivery = stream_provider.DeliveryCertainty.init(); + var evidence: stream_provider.AttemptEvidence = .{}; + var callback_context: u8 = 0; + try std.testing.expectError( + error.OrcaRouterApiKeyCredentialRequired, + agent_stream_provider.stream(std.testing.allocator, .{ + .credential = .{ .secret = "gateway-key", .source = .ai_gateway_api_key }, + .model = "orcarouter/fusion", + .retry_count = 1, + .messages = &.{}, + .tool_choice = .none, + .provider_options = .{}, + .trace_ctx = .{}, + .content_capture_limit = null, + .delivery = &delivery, + .attempt_evidence = &evidence, + .events = .{ .context = &callback_context, .emit_fn = struct { + fn ignore(_: *anyopaque, _: stream_provider.Event) void {} + }.ignore }, + .cancel_flag = &cancelled, + }), + ); + try std.testing.expectEqual(stream_provider.DeliveryCertainty.State.definitely_unsent, delivery.load()); +} diff --git a/src/gateway/orcarouter_models.zig b/src/gateway/orcarouter_models.zig new file mode 100644 index 000000000..974c906a1 --- /dev/null +++ b/src/gateway/orcarouter_models.zig @@ -0,0 +1,230 @@ +const std = @import("std"); +const model_catalog = @import("../core/gateway/model_catalog.zig"); +const gateway_provider = @import("../core/gateway/gateway_provider.zig"); +const io_mod = @import("../core/shared/io.zig"); +const secret = @import("../core/auth/secret.zig"); +const types = @import("../core/shared/types.zig"); +const gateway_client = @import("client.zig"); + +const max_catalog_models: usize = 512; +const max_model_id_bytes: usize = 1024; +const max_catalog_bytes: usize = 8 * 1024 * 1024; +const fetch_timeout_ms: i64 = 30_000; +const default_models_endpoint = "https://api.orcarouter.ai/v1/models"; +const e2e_models_endpoint_env = "FX_E2E_ORCAROUTER_MODELS_URL"; + +pub const model_catalog_provider = model_catalog.Provider{ + .fetch_fn = fetchCatalogForProvider, +}; + +pub const cli_model_catalog_provider = gateway_provider.CliModelCatalogProvider{ + .fetch_fn = fetchCliModelCatalog, +}; + +fn fetchCliModelCatalog( + _: ?*anyopaque, + alloc: std.mem.Allocator, + input: gateway_provider.CliModelCatalogInput, +) gateway_provider.CliModelCatalogResult { + return switch (model_catalog.fetchWithPublicFallback(model_catalog_provider, alloc, .{ + .access = input.access, + .endpoint = input.endpoint, + .cancel_flag = input.cancel_flag, + .view = .full, + })) { + .loaded => |loaded| blk: { + var catalog = loaded.catalog; + defer model_catalog.freeModelCatalog(alloc, &catalog); + const ids = model_catalog.projectModelIds(alloc, catalog.items) catch return .{ .failure = .{ + .access = loaded.provenance.access, + .anonymous_fallback_used = false, + .failure = .{ .category = .resource_exhausted }, + } }; + break :blk .{ .loaded = .{ + .ids = ids, + .provenance = loaded.provenance, + } }; + }, + .failed => |failure| .{ .failure = failure }, + }; +} + +fn fetchCatalogForProvider( + _: ?*anyopaque, + alloc: std.mem.Allocator, + input: model_catalog.FetchInput, +) std.mem.Allocator.Error!model_catalog.ProviderResult { + if (input.access.credentialSource() != .orcarouter_api_key) { + return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + } + const credential = input.access.authorizationCredential() orelse + return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + const request_url = modelsUrl(alloc) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .failure = .{ .category = .runtime } }; + }; + defer alloc.free(request_url); + + var fallback_cancel = std.atomic.Value(bool).init(false); + const cancel_flag = input.cancel_flag orelse &fallback_cancel; + var operation = FetchOperation{ + .alloc = alloc, + .url = request_url, + .credential = credential, + }; + var response = gateway_client.runBoundedHttpOperation( + FetchResponse, + alloc, + cancel_flag, + std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ + .clock = .awake, + .raw = .fromMilliseconds(fetch_timeout_ms), + }), + &operation, + ) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .failure = .{ + .category = if (err == error.Cancelled) .cancellation else .transport, + .retryable = err != error.Cancelled, + } }; + }; + defer response.deinit(alloc); + if (response.status != .ok) { + return .{ .failure = model_catalog.failureForHttpStatus(response.status) }; + } + return .{ .catalog = parseCatalog(alloc, response.body) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .failure = .{ .category = .malformed_response, .http_status = .ok } }; + } }; +} + +const FetchResponse = struct { + status: std.http.Status, + body: []u8, + + pub fn deinit(self: *FetchResponse, alloc: std.mem.Allocator) void { + secret.zeroAndFree(alloc, self.body); + self.* = undefined; + } +}; + +const FetchOperation = struct { + alloc: std.mem.Allocator, + url: []const u8, + credential: []const u8, + + pub fn run(self: *@This()) !FetchResponse { + var client: std.http.Client = .{ .allocator = self.alloc, .io = io_mod.getIo() }; + defer client.deinit(); + const auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{self.credential}); + defer secret.zeroAndFree(self.alloc, auth_header); + const body_buffer = try self.alloc.alloc(u8, max_catalog_bytes + 1); + defer secret.zeroAndFree(self.alloc, body_buffer); + var response_writer = std.Io.Writer.fixed(body_buffer); + const result = client.fetch(.{ + .location = .{ .url = self.url }, + .method = .GET, + .headers = .{ + .authorization = .{ .override = auth_header }, + .user_agent = .{ .override = gateway_client.user_agent }, + .accept_encoding = .omit, + }, + .extra_headers = &.{ + .{ .name = "accept", .value = "application/json" }, + }, + .response_writer = &response_writer, + .redirect_behavior = .unhandled, + }) catch |err| switch (err) { + error.WriteFailed => return error.OrcaRouterModelCatalogTooLarge, + else => return err, + }; + const body = response_writer.buffered(); + if (body.len > max_catalog_bytes) return error.OrcaRouterModelCatalogTooLarge; + return .{ + .status = result.status, + .body = try self.alloc.dupe(u8, body), + }; + } +}; + +fn modelsUrl(alloc: std.mem.Allocator) ![]u8 { + const base = io_mod.getenv(e2e_models_endpoint_env) orelse default_models_endpoint; + if (io_mod.getenv(e2e_models_endpoint_env) != null and !gateway_client.isLoopbackHttpUrl(base)) { + return error.InvalidE2EOrcaRouterModelsEndpoint; + } + return alloc.dupe(u8, base); +} + +fn parseCatalog( + alloc: std.mem.Allocator, + json_text: []const u8, +) !std.ArrayList(model_catalog.ModelCatalogEntry) { + var parsed = try std.json.parseFromSlice(std.json.Value, alloc, json_text, .{}); + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidOrcaRouterModelCatalog; + const models_value = parsed.value.object.get("data") orelse + return error.InvalidOrcaRouterModelCatalog; + if (models_value != .array or models_value.array.items.len > max_catalog_models) { + return error.InvalidOrcaRouterModelCatalog; + } + + var catalog: std.ArrayList(model_catalog.ModelCatalogEntry) = .empty; + errdefer model_catalog.freeModelCatalog(alloc, &catalog); + for (models_value.array.items) |value| { + if (value != .object) return error.InvalidOrcaRouterModelCatalog; + const object = value.object; + const id = try requiredString(object, "id"); + try validateModelId(id); + const owned_id = try alloc.dupe(u8, id); + errdefer alloc.free(owned_id); + const model_type = try alloc.dupe(u8, "language"); + errdefer alloc.free(model_type); + + try catalog.append(alloc, .{ + .id = owned_id, + .model_type = model_type, + .has_tool_use = true, + .has_reasoning = true, + .has_vision = true, + .has_file_input = true, + }); + } + return catalog; +} + +fn requiredString(object: std.json.ObjectMap, key: []const u8) ![]const u8 { + const value = object.get(key) orelse return error.InvalidOrcaRouterModelCatalog; + if (value != .string or value.string.len == 0) return error.InvalidOrcaRouterModelCatalog; + return value.string; +} + +fn validateModelId(id: []const u8) !void { + if (id.len == 0 or id.len > max_model_id_bytes) return error.InvalidOrcaRouterModelCatalog; + for (id) |byte| { + if (byte <= 0x20 or byte == 0x7f) return error.InvalidOrcaRouterModelCatalog; + } +} + +test "OrcaRouter catalog parser keeps data array model ids" { + const alloc = std.testing.allocator; + const json = + \\{"object":"list","data":[ + \\ {"id":"orcarouter/fusion","object":"model"}, + \\ {"id":"orcarouter/fusion-flash","object":"model"}, + \\ {"id":"openai/gpt-5.4-mini","object":"model"} + \\]} + ; + var catalog = try parseCatalog(alloc, json); + defer model_catalog.freeModelCatalog(alloc, &catalog); + + try std.testing.expectEqual(@as(usize, 3), catalog.items.len); + try std.testing.expectEqualStrings("orcarouter/fusion", catalog.items[0].id); + try std.testing.expect(catalog.items[0].has_tool_use); + try std.testing.expect(catalog.items[0].has_vision); +} + +test "OrcaRouter catalog URL uses the live models endpoint" { + const url = try modelsUrl(std.testing.allocator); + defer std.testing.allocator.free(url); + try std.testing.expectEqualStrings("https://api.orcarouter.ai/v1/models", url); +} diff --git a/src/main.zig b/src/main.zig index bfcedb050..e83792cd2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1788,6 +1788,7 @@ const App = struct { providers.gateway.permission_reviewer = null; providers.codex.permission_reviewer = null; providers.grok.permission_reviewer = null; + providers.orcarouter.permission_reviewer = null; } return providers; } @@ -4068,6 +4069,8 @@ test { _ = @import("gateway/xai_grok_models.zig"); _ = @import("gateway/xai_grok.zig"); _ = @import("gateway/xai_grok_permission_reviewer.zig"); + _ = @import("gateway/orcarouter_models.zig"); + _ = @import("gateway/orcarouter.zig"); _ = credentials; _ = @import("core/auth/oauth.zig"); _ = @import("core/auth/oauth_session.zig"); diff --git a/src/ui/footer/model_menu_presentation.zig b/src/ui/footer/model_menu_presentation.zig index 0fd7a38d3..468fd63f5 100644 --- a/src/ui/footer/model_menu_presentation.zig +++ b/src/ui/footer/model_menu_presentation.zig @@ -418,6 +418,7 @@ fn loadedCatalogStatusText(state: model_cache_runtime.ModelMenuCatalogState) ?[] .stored_key => "Gateway catalog: authenticated with the stored API key.", .chatgpt_subscription => "Codex catalog: authenticated with a subscription.", .grok_subscription => "Grok catalog: authenticated with a subscription.", + .orcarouter_api_key => "OrcaRouter catalog: authenticated with an API key.", }; } return null;