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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ To use an AI Gateway API key instead:
fx setup
```

Embedding hosts that inject provider authentication at the network boundary can set `FX_AUTH_MODE=host-managed`. In this mode, fx does not read, refresh, or write local model-provider credentials and does not add authentication-owned headers to Gateway, Codex, or Grok requests. The host must authenticate those forwarded requests.

Run fx from a project:

```bash
Expand Down
7 changes: 6 additions & 1 deletion src/acp/prompt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,12 @@ pub fn handlePrompt(
);
if (comptime @import("builtin").os.tag != .wasi) {
if (state.cfg.provider_set.select(session.provider).deferred_usage != null) {
if (session.credential_source) |source| {
if (session.credential_source == .host_managed) {
session.session_rt.usage.replaceHostManagedReconciliationAuthority(
alloc,
session.provider,
);
} else if (session.credential_source) |source| {
session.session_rt.usage.replaceProviderReconciliationCredential(
alloc,
session.provider,
Expand Down
155 changes: 97 additions & 58 deletions src/acp/server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,19 @@ pub fn selectCredentialForProvider(
state: *ServerState,
provider: model_provider.ProviderId,
) !bool {
if (state.cfg.auth_mode == .host_managed) {
state.credential_source = .host_managed;
state.credential_refresh_after_ms = null;
state.account_id = null;
state.gateway_team = null;
if (state.active_session) |*active| {
active.credential_source = .host_managed;
active.credential_refresh_after_ms = null;
active.api_key = &.{};
active.account_id = null;
}
return true;
}
const now_ms = io_mod.milliTimestamp();
if (state.active_session) |active| {
if (credentialMatchesProvider(active.credential_source, provider) and
Expand Down Expand Up @@ -1703,21 +1716,24 @@ fn loadConfiguredStartupState(state: *const ServerState, alloc: Allocator) !app_
}
if (state.cfg.home_override) |home_dir| {
if (state.cfg.workspace_root_override) |workspace_root| {
return app_lifecycle.loadEmbeddedStartupState(
var startup = try app_lifecycle.loadEmbeddedStartupState(
alloc,
home_dir,
workspace_root,
state.cfg.default_model,
state.cfg.default_agent_step_limit,
);
startup.auth_mode = state.cfg.auth_mode;
return startup;
}
}
return app_lifecycle.loadStartupState(
return app_lifecycle.loadStartupStateWithAuthMode(
alloc,
state.cfg.gateway_provider.oauth_transport,
state.cfg.secret_store,
state.cfg.default_model,
state.cfg.default_agent_step_limit,
state.cfg.auth_mode,
);
}

Expand Down Expand Up @@ -1785,33 +1801,53 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
state.gateway_source_preference = startup.credential_source_preference;
state.configured_model = try alloc.dupe(u8, startup.configured_model);

var startup_credential = startup.takeCredential();
defer if (startup_credential) |*credential| credential.deinit(alloc);
var routed_credential: ?credentials.Credential = null;
defer if (routed_credential) |*credential| credential.deinit(alloc);
const startup_matches_model = if (startup_credential) |credential|
credentialMatchesProvider(credential.source, state.provider)
else
false;
const startup_credential_is_final = startup_matches_model and
!credentials.sourceRefreshable(startup_credential.?.source);
const credential: *credentials.Credential = if (state.provider == .gateway and state.cfg.credential_override != null) override: {
routed_credential = .{
.token = try alloc.dupe(u8, state.cfg.credential_override.?),
.source = .ai_gateway_api_key,
if (state.cfg.auth_mode == .host_managed) {
state.api_key = &.{};
state.credential_source = .host_managed;
state.credential_refresh_after_ms = null;
state.account_id = null;
state.gateway_team = null;
} else {
var startup_credential = startup.takeCredential();
defer if (startup_credential) |*credential| credential.deinit(alloc);
var routed_credential: ?credentials.Credential = null;
defer if (routed_credential) |*credential| credential.deinit(alloc);
const startup_matches_model = if (startup_credential) |credential|
credentialMatchesProvider(credential.source, state.provider)
else
false;
const startup_credential_is_final = startup_matches_model and
!credentials.sourceRefreshable(startup_credential.?.source);
const credential: *credentials.Credential = if (state.provider == .gateway and state.cfg.credential_override != null) override: {
routed_credential = .{
.token = try alloc.dupe(u8, state.cfg.credential_override.?),
.source = .ai_gateway_api_key,
};
break :override &routed_credential.?;
} else if (startup_credential_is_final)
&startup_credential.?
else routed: {
routed_credential = try auth_runtime.prepareCredential(
alloc,
state.cfg.gateway_provider.oauth_transport,
state.cfg.secret_store,
state.provider,
if (state.provider == .gateway) startup.credential_source_preference else null,
);
if (routed_credential == null) {
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
.message = if (state.provider == .codex)
credentials.missing_chatgpt_credential_message
else if (state.provider == .grok)
credentials.missing_grok_credential_message
else
credentials.missing_credential_message,
});
}
break :routed &routed_credential.?;
};
break :override &routed_credential.?;
} else if (startup_credential_is_final)
&startup_credential.?
else routed: {
routed_credential = try auth_runtime.prepareCredential(
alloc,
state.cfg.gateway_provider.oauth_transport,
state.cfg.secret_store,
state.provider,
if (state.provider == .gateway) startup.credential_source_preference else null,
);
if (routed_credential == null) {
if (credential.token.len == 0) {
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
.message = if (state.provider == .codex)
Expand All @@ -1822,20 +1858,8 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
credentials.missing_credential_message,
});
}
break :routed &routed_credential.?;
};
if (credential.token.len == 0) {
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
.message = if (state.provider == .codex)
credentials.missing_chatgpt_credential_message
else if (state.provider == .grok)
credentials.missing_grok_credential_message
else
credentials.missing_credential_message,
});
adoptServerCredential(state, credential);
}
adoptServerCredential(state, credential);

state.permission_mode = startup.permission_mode;
state.permission_rules = startup.takePermissionRules();
Expand Down Expand Up @@ -1870,12 +1894,15 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
state.alloc,
startup_catalog,
.{
.access = credentials.catalogAccessForCredentialAndAccount(
state.credential_source,
state.api_key,
state.gateway_team,
state.account_id,
),
.access = if (state.cfg.auth_mode == .host_managed)
.host_managed
else
credentials.catalogAccessForCredentialAndAccount(
state.credential_source,
state.api_key,
state.gateway_team,
state.account_id,
),
.endpoint = state.cfg.gateway_models_path,
.cancel_flag = &catalog_cancel_flag,
},
Expand Down Expand Up @@ -2094,7 +2121,9 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
.message = "Subscription provider switching is unavailable in this WASM runtime",
});
}
var staged_credential = if (target == .gateway and state.cfg.credential_override != null)
var staged_credential: ?credentials.Credential = if (state.cfg.auth_mode == .host_managed)
null
else if (target == .gateway and state.cfg.credential_override != null)
credentials.Credential{
.token = try alloc.dupe(u8, state.cfg.credential_override.?),
.source = .ai_gateway_api_key,
Expand All @@ -2117,24 +2146,27 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
credentials.missing_credential_message,
});
};
defer staged_credential.deinit(alloc);
if (!model_provider.authorizesCredential(target, staged_credential.source)) {
defer if (staged_credential) |*credential| credential.deinit(alloc);
if (staged_credential) |credential| if (!model_provider.authorizesCredential(target, credential.source)) {
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
.message = "Credential cannot authorize the selected provider",
});
}
};
const catalog_provider = catalogProviderFor(state, target) orelse
return state.writer.writeError(alloc, msg.id, .{
.code = ErrorCode.invalid_request,
.message = "Selected provider is unavailable in this host",
});
const access = credentials.catalogAccessForCredentialAndAccount(
staged_credential.source,
staged_credential.token,
staged_credential.gatewayTeam(),
staged_credential.accountId(),
);
const access: credentials.CatalogAccess = if (state.cfg.auth_mode == .host_managed)
.host_managed
else
credentials.catalogAccessForCredentialAndAccount(
staged_credential.?.source,
staged_credential.?.token,
staged_credential.?.gatewayTeam(),
staged_credential.?.accountId(),
);
const fetched = try catalog_provider.fetch(alloc, .{
.access = access,
.endpoint = state.cfg.gateway_models_path,
Expand Down Expand Up @@ -2186,7 +2218,14 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me
});
};
state.capability_resolver.adoptOwnedCatalog(alloc, &catalog);
adoptServerCredential(state, &staged_credential);
if (staged_credential) |*credential| {
adoptServerCredential(state, credential);
} else {
state.credential_source = .host_managed;
session.credential_source = .host_managed;
session.api_key = &.{};
session.account_id = null;
}
}
} else if (std.mem.eql(u8, config_id, "mode")) {
if (state.active_session) |*session| {
Expand Down
44 changes: 27 additions & 17 deletions src/builtins/gateway.zig
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,8 @@ fn streamAgentCompletion(
alloc: Allocator,
request: agent_stream_provider_contract.ModelRequest,
) anyerror!agent_stream_provider_contract.Result {
if (request.credential.source == .chatgpt_subscription or request.credential.source == .grok_subscription) {
const credential_source = request.credential.credentialSource();
if (credential_source == .chatgpt_subscription or credential_source == .grok_subscription) {
return agent_stream_provider_contract.failResult(
error.SubscriptionCredentialCannotAuthorizeGateway,
);
Expand All @@ -537,8 +538,8 @@ fn streamAgentCompletion(
defer if (request.prepared_request_body == null) alloc.free(payload);
var events = request.events;
const stream_request = gateway_client.StreamRequest{
.api_key = request.credential.secret,
.team = request.credential.tenant,
.api_key = request.credential.secret(),
.team = request.credential.tenant(),
.session_id = request.session_id,
.model = request.model,
.retry_count = request.retry_count,
Expand Down Expand Up @@ -618,17 +619,17 @@ fn gatewayUsageReference(
completion: shared_types.ModelCompletion,
) ?agent_stream_provider_contract.DeferredUsageReference {
const generation_id = completion.generation_id orelse return null;
const source = request.credential.source orelse return null;
const source = request.credential.credentialSource() orelse return null;
return .{
.provider = .gateway,
.generation_id = generation_id,
.scope = gateway_client.generationBaseUrl(),
.tenant = request.credential.tenant,
.account_id = request.credential.account_id,
.tenant = request.credential.tenant(),
.account_id = request.credential.accountId(),
.credential_source = source,
.credential_identity = credential_authority.derive(
source,
request.credential.account_id,
request.credential.accountId(),
),
};
}
Expand Down Expand Up @@ -908,7 +909,7 @@ fn executeWebSearchProvider(
progress_ctx: ?*anyopaque,
) !Response {
return executeGatewayWorker(alloc, .{
.api_key = inputs.api_key,
.api_key = if (inputs.credential_source == .host_managed) null else inputs.api_key,
.credential_source = inputs.credential_source,
.team = inputs.gateway_team,
.model = inputs.worker_model,
Expand Down Expand Up @@ -986,7 +987,7 @@ pub const StreamFn = *const fn (
var default_stream_ctx: u8 = 0;

pub const GatewayWorkerConfig = struct {
api_key: []const u8,
api_key: ?[]const u8,
credential_source: ?shared_types.CredentialSource = null,
team: ?[]const u8 = null,
model: []const u8,
Expand Down Expand Up @@ -1014,7 +1015,9 @@ pub fn executeGatewayWorker(
on_progress: ?ProgressFn,
progress_ctx: ?*anyopaque,
) !Response {
if (config.api_key.len == 0 or config.model.len == 0 or config.chat_url.len == 0) {
if ((config.api_key == null and config.credential_source != .host_managed) or
config.model.len == 0 or config.chat_url.len == 0)
{
return error.MissingGatewaySearchConfiguration;
}
if (request.cancel_flag.load(.seq_cst)) return error.Cancelled;
Expand Down Expand Up @@ -1044,7 +1047,7 @@ pub fn executeGatewayWorker(
var stream = config.stream_fn(
config.stream_ctx,
alloc,
config.api_key,
config.api_key orelse "",
config.team,
config.model,
@max(config.retry_count, 1),
Expand Down Expand Up @@ -1074,11 +1077,18 @@ pub fn executeGatewayWorker(
}
if (!builtin.is_test and stream.status == .ok and std.meta.activeTag(usage_outcome) == .deferred) {
if (config.usage) |ledger| {
ledger.startDeferredReconciliation(
config.usage_allocator,
usage_outcome.deferred,
config.api_key,
);
if (config.api_key) |api_key| {
ledger.startDeferredReconciliation(
config.usage_allocator,
usage_outcome.deferred,
api_key,
);
} else if (config.credential_source == .host_managed) {
ledger.startHostManagedDeferredReconciliation(
config.usage_allocator,
usage_outcome.deferred,
);
}
}
}
if (stream.status != .ok) return error.GatewayRequestFailed;
Expand Down Expand Up @@ -1179,7 +1189,7 @@ fn streamGatewayWorker(
return gateway_client.streamGatewayProviderToolCompletionBounded(
alloc,
.{
.api_key = api_key,
.api_key = if (api_key.len > 0) api_key else null,
.team = team,
.model = model,
.retry_count = request_retry_count,
Expand Down
Loading
Loading