diff --git a/README.md b/README.md index 27ee6b9bb..d874bd6aa 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ The OpenAI Codex route uses ChatGPT subscription access directly and never sends The Grok route uses subscription access directly at xAI and never sends its OAuth token to Vercel AI Gateway or OpenAI. Its session is stored privately at `~/.fx/grok-auth.json`, refreshed when needed, and used only with the authenticated xAI catalog and Responses API. +Codex and Grok discover current stable client versions from upstream release metadata without requiring either CLI to be installed. fx caches release metadata for one minute. Opening `/model` or requesting ACP model options refreshes an expired subscription catalog. If a release lookup temporarily fails, fx uses the last successfully fetched version. + To use an AI Gateway API key instead: ```bash diff --git a/src/acp/server.zig b/src/acp/server.zig index 3fd6b5e61..743233b88 100644 --- a/src/acp/server.zig +++ b/src/acp/server.zig @@ -2036,6 +2036,7 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me }); if (comptime !host_target.is_wasm) { if (session.provider != .gateway) { + try refreshModelCatalogForOptions(state); var model_available = false; if (state.capability_resolver.catalogEntries()) |entries| { for (entries) |entry| { @@ -2217,7 +2218,7 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me .message = "Failed to persist session provider", }); }; - state.capability_resolver.adoptOwnedCatalog(alloc, &catalog); + state.capability_resolver.adoptOwnedCatalog(alloc, catalog_provider, access, &catalog); if (staged_credential) |*credential| { adoptServerCredential(state, credential); } else { @@ -2235,6 +2236,7 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me } } + try refreshModelCatalogForOptions(state); const current_model = if (state.active_session) |s| s.model else state.selected_model; const current_mode: []const u8 = if (state.active_session) |s| s.mode else state.cfg.mode_registry.default_mode_id; @@ -2259,6 +2261,29 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me try state.writer.writeResponse(alloc, msg.id, out.writer.buffered()); } +pub fn refreshModelCatalogForOptions(state: *ServerState) !void { + if (comptime host_target.is_wasm) return; + if (state.cfg.minimal_kernel) return; + const active = if (state.active_session) |*session| session else return; + const provider = catalogProviderFor(state, active.provider) orelse return; + std.debug.assert(state.active_prompt == null); + // Restoring the same session can leave its previous cancellation flag set. + var cancel_flag = std.atomic.Value(bool).init(false); + try state.capability_resolver.refreshIfDue(state.alloc, provider, .{ + .access = if (state.cfg.auth_mode == .host_managed) + .host_managed + else + credentials.catalogAccessForCredentialAndAccount( + active.credential_source, + active.api_key, + state.gateway_team, + active.account_id, + ), + .endpoint = state.cfg.gateway_models_path, + .cancel_flag = &cancel_flag, + }); +} + fn commitActiveSessionProvider( alloc: Allocator, session: *ActiveSessionState, diff --git a/src/acp/sessions.zig b/src/acp/sessions.zig index 6712ac056..d58a483d4 100644 --- a/src/acp/sessions.zig +++ b/src/acp/sessions.zig @@ -301,6 +301,7 @@ fn writeNewSessionResponse( msg: *jsonrpc.Message, session_id: []const u8, ) !void { + try server.refreshModelCatalogForOptions(state); var out: std.Io.Writer.Allocating = .init(alloc); defer out.deinit(); @@ -845,6 +846,7 @@ fn writeLoadSessionResponse( msg: *jsonrpc.Message, model: []const u8, ) !void { + try server.refreshModelCatalogForOptions(state); var out: std.Io.Writer.Allocating = .init(alloc); defer out.deinit(); try out.writer.writeAll("{\"configOptions\":["); diff --git a/src/core/app/model_cache_runtime.zig b/src/core/app/model_cache_runtime.zig index fe745e815..c62e512b8 100644 --- a/src/core/app/model_cache_runtime.zig +++ b/src/core/app/model_cache_runtime.zig @@ -320,7 +320,7 @@ pub const Runtime = struct { provider: model_catalog.Provider, access: credentials.CatalogAccess, ) void { - if (!self.beginLoad(access)) return; + if (!self.beginLoad(access, provider.refresh_interval_ms)) return; const owned_access = OwnedCatalogAccess.init(self.alloc, access) catch { self.markFailed(.{ @@ -350,7 +350,7 @@ pub const Runtime = struct { provider: model_catalog.Provider, access: credentials.CatalogAccess, ) void { - if (!self.beginLoad(access)) return; + if (!self.beginLoad(access, provider.refresh_interval_ms)) return; const result = model_catalog.fetchWithPublicFallback(provider, self.alloc, .{ .access = access, @@ -393,7 +393,7 @@ pub const Runtime = struct { self.mutex.unlock(io_mod.getIo()); } - fn beginLoad(self: *Self, access: credentials.CatalogAccess) bool { + fn beginLoad(self: *Self, access: credentials.CatalogAccess, refresh_interval_ms: ?i64) bool { self.finishThreadIfDone(); const requested_access = model_catalog.AccessMetadata.init(access); @@ -424,13 +424,17 @@ pub const Runtime = struct { const now = io_mod.milliTimestamp(); self.mutex.lockUncancelable(io_mod.getIo()); + const expired = if (refresh_interval_ms) |interval| + now < self.last_attempt_ms or now - self.last_attempt_ms >= interval + else + false; const should_load = switch (self.state) { .idle => true, .failed => now - self.last_attempt_ms >= 1000, .ready => if (self.outcome.last_failure) |failed| - failed.failure.retryable and now - self.last_attempt_ms >= 1000 + expired or (failed.failure.retryable and now - self.last_attempt_ms >= 1000) else - false, + expired, .loading => false, }; if (!should_load) { @@ -931,6 +935,24 @@ const StaleCatalog = struct { } }; +test "model cache expires successful catalogs only when the provider requests refresh" { + for ([_]bool{ false, true }) |expires| { + var runtime = Runtime.init(std.testing.allocator, "/v1/models"); + defer runtime.deinit(); + var source = AuthChangeCatalog{ .model_id = "first" }; + var provider = source.provider(); + provider.refresh_interval_ms = if (expires) 60_000 else null; + runtime.loadCooperative(provider, .{ .public_only = .no_credential }); + source.model_id = "new-release"; + runtime.loadCooperative(provider, .{ .public_only = .no_credential }); + try std.testing.expectEqual(@as(usize, 1), source.calls); + runtime.last_attempt_ms -= 60_000; + runtime.loadCooperative(provider, .{ .public_only = .no_credential }); + try std.testing.expectEqual(@as(usize, if (expires) 2 else 1), source.calls); + try std.testing.expectEqualStrings(if (expires) "new-release" else "first", runtime.catalog.items[0].id); + } +} + test "model cache clears an old failure after a clean empty refresh" { var runtime = Runtime.init(std.testing.allocator, "/v1/models"); defer runtime.deinit(); diff --git a/src/core/gateway/gateway_provider.zig b/src/core/gateway/gateway_provider.zig index fdca40e90..03124d8fa 100644 --- a/src/core/gateway/gateway_provider.zig +++ b/src/core/gateway/gateway_provider.zig @@ -2,7 +2,9 @@ const std = @import("std"); const credentials = @import("../auth/credentials.zig"); const oauth_transport = @import("../auth/oauth_transport.zig"); const model_capabilities = @import("../config/model_capabilities.zig"); +const model_provider = @import("../config/model_provider.zig"); const debug_trace = @import("../shared/debug_trace.zig"); +const io_mod = @import("../shared/io.zig"); const output_contracts = @import("../output/output_contracts.zig"); const model_catalog = @import("model_catalog.zig"); const model_catalog_metadata = @import("model_catalog_metadata.zig"); @@ -149,11 +151,58 @@ const CapabilityResolverState = enum { pub const CapabilityResolver = struct { catalog: std.ArrayList(model_catalog.ModelCatalogEntry) = .empty, state: CapabilityResolverState = .idle, + last_attempt_ms: i64 = 0, + requested_access: ?model_catalog.AccessMetadata = null, + provider_id: ?model_provider.ProviderId = null, pub fn deinit(self: *CapabilityResolver, alloc: Allocator) void { model_catalog.freeModelCatalog(alloc, &self.catalog); } + /// The owner must exclude capability readers during refresh. Borrowed + /// catalog entries stay valid until the next exclusive refresh or adoption. + pub fn refreshIfDue( + self: *CapabilityResolver, + alloc: Allocator, + provider: model_catalog.Provider, + input: model_catalog.FetchInput, + ) model_capabilities.ResolveError!void { + const now = io_mod.milliTimestamp(); + const requested = model_catalog.AccessMetadata.init(input.access); + const access_changed = self.provider_id != provider.provider_id or + self.requested_access == null or !std.meta.eql(self.requested_access.?, requested); + const expired = if (provider.refresh_interval_ms) |interval| + now < self.last_attempt_ms or now - self.last_attempt_ms >= interval + else + false; + if (self.state != .idle and !access_changed and !expired) return; + + const result = model_catalog.fetchWithPublicFallback(provider, alloc, input); + const loaded = switch (result) { + .loaded => |loaded| loaded, + .failed => |failed| { + if (failed.failure.category == .cancellation) return error.Cancelled; + self.last_attempt_ms = now; + self.requested_access = requested; + self.provider_id = provider.provider_id; + if (access_changed) { + if (self.catalog.items.len > 0) debug_trace.logf("gateway", "dropping model catalog after access changed entries={d}", .{self.catalog.items.len}); + model_catalog.freeModelCatalog(alloc, &self.catalog); + self.catalog = .empty; + } + if (self.catalog.items.len == 0) self.state = .failed; + debug_trace.logf("gateway", "model catalog refresh failed category={t} retained={}", .{ failed.failure.category, self.state == .ready }); + return; + }, + }; + model_catalog.freeModelCatalog(alloc, &self.catalog); + self.catalog = loaded.catalog; + self.state = .ready; + self.last_attempt_ms = now; + self.requested_access = requested; + self.provider_id = provider.provider_id; + } + pub fn resolve( self: *CapabilityResolver, alloc: Allocator, @@ -163,30 +212,7 @@ pub const CapabilityResolver = struct { fallback: model_capabilities.Capabilities, ) model_capabilities.ResolveError!model_capabilities.Capabilities { if (self.state == .idle) { - const result = model_catalog.fetchWithPublicFallback(provider, alloc, input); - const loaded = switch (result) { - .loaded => |loaded| loaded, - .failed => |failed| { - const failure = failed.failure; - if (failure.category == .cancellation) { - debug_trace.logf( - "gateway", - "model catalog lookup outcome=cancelled model={s}", - .{model}, - ); - return failCapabilities(error.Cancelled); - } - self.state = .failed; - debug_trace.logf( - "gateway", - "model catalog lookup outcome=fetch_failed model={s} category={t}", - .{ model, failure.category }, - ); - return fallback; - }, - }; - self.catalog = loaded.catalog; - self.state = .ready; + try self.refreshIfDue(alloc, provider, input); } if (self.state == .failed) { @@ -244,12 +270,17 @@ pub const CapabilityResolver = struct { pub fn adoptOwnedCatalog( self: *CapabilityResolver, alloc: Allocator, + provider: model_catalog.Provider, + access: credentials.CatalogAccess, owned_catalog: *std.ArrayList(model_catalog.ModelCatalogEntry), ) void { model_catalog.freeModelCatalog(alloc, &self.catalog); self.catalog = owned_catalog.*; owned_catalog.* = .empty; self.state = .ready; + self.last_attempt_ms = io_mod.milliTimestamp(); + self.requested_access = .init(access); + self.provider_id = provider.provider_id; } }; @@ -333,6 +364,67 @@ const FakeCatalog = struct { } }; +test "capability resolver refreshes expired snapshots at the owner boundary and retains a usable catalog" { + const alloc = std.testing.allocator; + var fake = FakeCatalog{ .outcome = .unavailable }; + var provider = fake.provider(); + provider.refresh_interval_ms = 60_000; + var resolver: CapabilityResolver = .{}; + defer resolver.deinit(alloc); + const input = model_catalog.FetchInput{ .endpoint = "https://example.invalid" }; + try resolver.refreshIfDue(alloc, provider, input); + try std.testing.expect(resolver.catalogEntries() == null); + fake.outcome = .ready; + try resolver.refreshIfDue(alloc, provider, input); + try std.testing.expectEqual(@as(usize, 1), fake.calls); + resolver.last_attempt_ms -= 60_000; + // Worker capability reads keep the established snapshot until its owner refreshes. + _ = try resolver.resolve(alloc, provider, input, "provider/model", .{}); + try std.testing.expectEqual(@as(usize, 1), fake.calls); + try resolver.refreshIfDue(alloc, provider, input); + try std.testing.expectEqual(@as(usize, 2), fake.calls); + try std.testing.expectEqual(@as(usize, 1), resolver.catalogEntries().?.len); + fake.outcome = .unavailable; + resolver.last_attempt_ms -= 60_000; + try resolver.refreshIfDue(alloc, provider, input); + try std.testing.expectEqual(@as(usize, 3), fake.calls); + try std.testing.expectEqualStrings("provider/model", resolver.catalogEntries().?[0].id); +} + +test "capability refresh does not retain a catalog for changed access" { + const alloc = std.testing.allocator; + var fake = FakeCatalog{ .outcome = .ready }; + var resolver: CapabilityResolver = .{}; + defer resolver.deinit(alloc); + try resolver.refreshIfDue(alloc, fake.provider(), .{ .endpoint = "https://example.invalid" }); + fake.outcome = .unavailable; + try resolver.refreshIfDue(alloc, fake.provider(), .{ + .endpoint = "https://example.invalid", + .access = .host_managed, + }); + try std.testing.expect(resolver.catalogEntries() == null); + try std.testing.expectEqual(@as(usize, 2), fake.calls); +} + +test "capability refresh keys host-managed catalogs by provider" { + const alloc = std.testing.allocator; + var fake = FakeCatalog{ .outcome = .ready }; + var provider = fake.provider(); + provider.refresh_interval_ms = 60_000; + var resolver: CapabilityResolver = .{}; + defer resolver.deinit(alloc); + const input = model_catalog.FetchInput{ .endpoint = "https://example.invalid", .access = .host_managed }; + try resolver.refreshIfDue(alloc, provider, input); + provider.provider_id = .codex; + try resolver.refreshIfDue(alloc, provider, input); + try std.testing.expectEqual(@as(usize, 2), fake.calls); + fake.outcome = .unavailable; + provider.provider_id = .grok; + try resolver.refreshIfDue(alloc, provider, input); + try std.testing.expectEqual(@as(usize, 3), fake.calls); + try std.testing.expect(resolver.catalogEntries() == null); +} + test "available capabilities never fetch and use a completed catalog snapshot" { const alloc = std.testing.allocator; var fake = FakeCatalog{ .outcome = .ready }; diff --git a/src/core/gateway/model_catalog.zig b/src/core/gateway/model_catalog.zig index 8c126a0ef..72fa470ff 100644 --- a/src/core/gateway/model_catalog.zig +++ b/src/core/gateway/model_catalog.zig @@ -1,5 +1,6 @@ const std = @import("std"); const credentials = @import("../auth/credentials.zig"); +const model_provider = @import("../config/model_provider.zig"); const collections = @import("../shared/collections.zig"); const debug_trace = @import("../shared/debug_trace.zig"); const io_mod = @import("../shared/io.zig"); @@ -138,6 +139,8 @@ pub const Provider = struct { /// When set, context must remain valid until every in-flight `fetch` returns. context: ?*anyopaque = null, fetch_fn: FetchFn, + provider_id: model_provider.ProviderId = .gateway, + refresh_interval_ms: ?i64 = null, /// Returns owned catalog entries; the caller frees them with `freeModelCatalog`. pub fn fetch(self: Provider, alloc: Allocator, input: FetchInput) Allocator.Error!ProviderResult { diff --git a/src/core/gateway/provider_versions.zig b/src/core/gateway/provider_versions.zig new file mode 100644 index 000000000..884d2bee6 --- /dev/null +++ b/src/core/gateway/provider_versions.zig @@ -0,0 +1,248 @@ +const std = @import("std"); +const io_mod = @import("../shared/io.zig"); +const debug_trace = @import("../shared/debug_trace.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); +const host_target = @import("../hosts/target.zig"); +const update_target = @import("../upgrade/update_target.zig"); + +const Allocator = std.mem.Allocator; +const cache_dir_name = "provider-versions"; +const max_cache_bytes = 256; + +pub const refresh_interval_ms: i64 = 60_000; +pub const Provider = enum { codex, grok }; +pub const Error = Allocator.Error || error{ Cancelled, ProviderVersionUnavailable }; + +pub const Version = struct { + bytes: [update_target.max_version_bytes]u8, + len: u8, + + pub fn parse(raw: []const u8) ?Version { + const value = update_target.normalizeVersion(std.mem.trim(u8, raw, " \r\n\t")); + if (!update_target.isValidVersion(value)) return null; + var version: Version = .{ .bytes = [_]u8{0} ** update_target.max_version_bytes, .len = @intCast(value.len) }; + @memcpy(version.bytes[0..value.len], value); + return version; + } + + pub fn slice(self: *const Version) []const u8 { + return self.bytes[0..self.len]; + } +}; + +pub const Lookup = struct { + context: ?*anyopaque, + fetch: *const fn (?*anyopaque, Allocator, Provider) Error!Version, +}; + +const Cached = struct { + version: Version, + checked_at_ms: i64, + + fn fresh(self: Cached, now_ms: i64) bool { + const age = std.math.sub(i64, now_ms, self.checked_at_ms) catch return false; + return age >= 0 and age < refresh_interval_ms; + } +}; + +const Cache = struct { + context: ?*anyopaque = null, + load: *const fn (?*anyopaque, Allocator, Provider) anyerror!?Cached = loadCache, + save: *const fn (?*anyopaque, Allocator, Provider, Cached) anyerror!void = saveCache, +}; + +pub fn resolve(alloc: Allocator, provider: Provider, lookup: Lookup) Error!Version { + return resolveWithCache(alloc, provider, lookup, .{}, io_mod.milliTimestamp()); +} + +fn resolveWithCache(alloc: Allocator, provider: Provider, lookup: Lookup, cache: Cache, now_ms: i64) Error!Version { + const cached = cache.load(cache.context, alloc, provider) catch |err| blk: { + if (err == error.OutOfMemory) return error.OutOfMemory; + debug_trace.logf("models", "provider version cache unreadable provider={t} err={s}", .{ provider, @errorName(err) }); + break :blk null; + }; + if (cached) |entry| if (entry.fresh(now_ms)) return entry.version; + + const version = lookup.fetch(lookup.context, alloc, provider) catch |err| blk: { + if (err == error.OutOfMemory or err == error.Cancelled) return err; + const previous = cached orelse return err; + debug_trace.logf("models", "provider version lookup unavailable provider={t}; using cached version={s}", .{ provider, previous.version.slice() }); + break :blk previous.version; + }; + // The timestamp also bounds retries when the release service is unavailable. + cache.save(cache.context, alloc, provider, .{ .version = version, .checked_at_ms = now_ms }) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + debug_trace.logf("models", "provider version cache write failed provider={t} err={s}", .{ provider, @errorName(err) }); + }; + return version; +} + +fn cacheFile(provider: Provider) []const u8 { + return switch (provider) { + .codex => "codex.json", + .grok => "grok.json", + }; +} + +fn loadCache(_: ?*anyopaque, alloc: Allocator, provider: Provider) !?Cached { + if (comptime host_target.is_wasm) return null; + const home = io_mod.getenv("HOME") orelse return null; + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{}) catch |err| { + if (err == error.FileNotFound) return null; + return err; + }; + defer home_dir.close(io_mod.getIo()); + var profile_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ .follow_symlinks = false }) catch |err| { + if (err == error.FileNotFound) return null; + return err; + }; + defer profile_dir.close(io_mod.getIo()); + var cache_dir = profile_dir.openDir(io_mod.getIo(), cache_dir_name, .{ .follow_symlinks = false }) catch |err| { + if (err == error.FileNotFound) return null; + return err; + }; + defer cache_dir.close(io_mod.getIo()); + return readCached(alloc, cache_dir, provider); +} + +fn readCached(alloc: Allocator, dir: std.Io.Dir, provider: Provider) !?Cached { + var file = dir.openFile(io_mod.getIo(), cacheFile(provider), .{ + .follow_symlinks = false, + .allow_directory = false, + .resolve_beneath = true, + }) catch |err| { + if (err == error.FileNotFound) return null; + return err; + }; + defer file.close(io_mod.getIo()); + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.nlink != 1) return error.InvalidProviderVersionCache; + const bytes = try io_mod.readFileToEnd(alloc, &file, max_cache_bytes); + defer alloc.free(bytes); + const Record = struct { version: []const u8, checked_at_ms: i64 }; + const parsed = try std.json.parseFromSlice(Record, alloc, bytes, .{}); + defer parsed.deinit(); + return .{ + .version = Version.parse(parsed.value.version) orelse return error.InvalidProviderVersionCache, + .checked_at_ms = parsed.value.checked_at_ms, + }; +} + +fn saveCache(_: ?*anyopaque, alloc: Allocator, provider: Provider, cached: Cached) !void { + if (comptime host_target.is_wasm) return; + const home = io_mod.getenv("HOME") orelse return; + try saveCacheAtHome(alloc, provider, cached, home); +} + +fn saveCacheAtHome(alloc: Allocator, provider: Provider, cached: Cached, home: []const u8) !void { + var home_dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }); + defer home_dir.close(io_mod.getIo()); + var profile_dir = try io_mod.openOrCreateVerifiedPrivateDirFromDir(home_dir, profile_paths.root_dir_name); + defer profile_dir.close(); + var cache_dir = try io_mod.openOrCreateVerifiedPrivateDir(&profile_dir, cache_dir_name); + defer cache_dir.close(); + try writeCached(alloc, &cache_dir, provider, cached); +} + +fn writeCached(alloc: Allocator, dir: *io_mod.VerifiedDir, provider: Provider, cached: Cached) !void { + var bytes: [max_cache_bytes]u8 = undefined; + const text = try std.fmt.bufPrint(&bytes, "{{\"version\":\"{s}\",\"checked_at_ms\":{d}}}\n", .{ cached.version.slice(), cached.checked_at_ms }); + try io_mod.durableReplaceVerified(alloc, dir, cacheFile(provider), text); +} + +test "provider versions accept bounded stable releases and reject header or URL data" { + const version = Version.parse(" v0.153.1\n").?; + try std.testing.expectEqualStrings("0.153.1", version.slice()); + for ([_][]const u8{ "", "latest", "1.2", "1.2.3.4", "1.2.3?x=y", "1.2.3\r\nHeader: value", "4294967296.1.2" }) |raw| { + try std.testing.expect(Version.parse(raw) == null); + } +} + +test "provider version cache round trips and rejects damaged data" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + var dir = try io_mod.openOrCreateVerifiedPrivateDirFromDir(tmp.dir, "versions"); + defer dir.close(); + try std.testing.expect((try readCached(alloc, dir.dir, .codex)) == null); + try writeCached(alloc, &dir, .codex, .{ .version = Version.parse("0.153.1").?, .checked_at_ms = 500 }); + const cached = (try readCached(alloc, dir.dir, .codex)).?; + try std.testing.expectEqualStrings("0.153.1", cached.version.slice()); + try std.testing.expect(cached.fresh(500)); + try std.testing.expect(!cached.fresh(499)); + try std.testing.expect(!cached.fresh(500 + refresh_interval_ms)); + try io_mod.durableReplaceVerified(alloc, &dir, cacheFile(.codex), "{\"version\":\"bad\",\"checked_at_ms\":0}"); + try std.testing.expectError(error.InvalidProviderVersionCache, readCached(alloc, dir.dir, .codex)); +} + +test "provider version cache creates its profile from a fresh home" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + try saveCacheAtHome(alloc, .grok, .{ .version = Version.parse("1.0.13").?, .checked_at_ms = 500 }, home); + var dir = try tmp.dir.openDir(std.testing.io, ".fx/provider-versions", .{}); + defer dir.close(std.testing.io); + const cached = (try readCached(alloc, dir, .grok)).?; + try std.testing.expectEqualStrings("1.0.13", cached.version.slice()); +} + +const TestState = struct { + cached: ?Cached = null, + current: Version = Version.parse("0.153.1").?, + failure: ?Error = null, + write_failed: bool = false, + fetch_count: usize = 0, + + fn fetch(ctx: ?*anyopaque, _: Allocator, _: Provider) Error!Version { + const self: *TestState = @ptrCast(@alignCast(ctx.?)); + self.fetch_count += 1; + if (self.failure) |err| return err; + return self.current; + } + + fn load(ctx: ?*anyopaque, _: Allocator, _: Provider) !?Cached { + const self: *TestState = @ptrCast(@alignCast(ctx.?)); + return self.cached; + } + + fn save(ctx: ?*anyopaque, _: Allocator, _: Provider, cached: Cached) !void { + const self: *TestState = @ptrCast(@alignCast(ctx.?)); + if (self.write_failed) return error.TestCacheWriteFailed; + self.cached = cached; + } + + fn resolveAt(self: *TestState, now_ms: i64) Error!Version { + return resolveWithCache(std.testing.allocator, .codex, .{ .context = self, .fetch = fetch }, .{ .context = self, .load = load, .save = save }, now_ms); + } +}; + +test "provider versions refresh automatically and preserve the last valid cache on lookup failure" { + var state: TestState = .{}; + _ = try state.resolveAt(0); + state.current = Version.parse("0.154.0").?; + var version = try state.resolveAt(refresh_interval_ms - 1); + try std.testing.expectEqualStrings("0.153.1", version.slice()); + try std.testing.expectEqual(@as(usize, 1), state.fetch_count); + version = try state.resolveAt(refresh_interval_ms); + try std.testing.expectEqualStrings("0.154.0", version.slice()); + state.failure = error.ProviderVersionUnavailable; + version = try state.resolveAt(2 * refresh_interval_ms); + try std.testing.expectEqualStrings("0.154.0", version.slice()); + _ = try state.resolveAt(2 * refresh_interval_ms + 1); + try std.testing.expectEqual(@as(usize, 3), state.fetch_count); +} + +test "provider version failures do not fabricate a version or suppress cancellation" { + var state: TestState = .{ .failure = error.ProviderVersionUnavailable }; + try std.testing.expectError(error.ProviderVersionUnavailable, state.resolveAt(0)); + state.failure = null; + state.write_failed = true; + const version = try state.resolveAt(0); + try std.testing.expectEqualStrings("0.153.1", version.slice()); + try std.testing.expect(state.cached == null); + state.cached = .{ .version = version, .checked_at_ms = 0 }; + state.failure = error.Cancelled; + try std.testing.expectError(error.Cancelled, state.resolveAt(refresh_interval_ms)); +} diff --git a/src/gateway/openai_codex_models.zig b/src/gateway/openai_codex_models.zig index 250e3235c..8eec6bf01 100644 --- a/src/gateway/openai_codex_models.zig +++ b/src/gateway/openai_codex_models.zig @@ -7,6 +7,8 @@ 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 versions = @import("../core/gateway/provider_versions.zig"); +const version_lookup = @import("provider_versions.zig"); const max_catalog_models: usize = 128; const max_model_id_bytes: usize = 1024; @@ -15,11 +17,12 @@ const fetch_timeout_ms: i64 = 30_000; const default_models_endpoint = "https://chatgpt.com/backend-api/codex/models"; const e2e_models_endpoint_env = "FX_E2E_OPENAI_CODEX_MODELS_URL"; -pub const protocol_client_version = "0.148.0"; pub const reviewer_model = "gpt-5.6-luna"; pub const model_catalog_provider = model_catalog.Provider{ .fetch_fn = fetchCatalogForProvider, + .provider_id = .codex, + .refresh_interval_ms = versions.refresh_interval_ms, }; pub const cli_model_catalog_provider = gateway_provider.CliModelCatalogProvider{ @@ -70,14 +73,28 @@ fn fetchCatalogForProvider( }; break :account owned_account_id.?; } else null; - const request_url = modelsUrl(alloc) catch |err| { + var fallback_cancel = std.atomic.Value(bool).init(false); + const cancel_flag = input.cancel_flag orelse &fallback_cancel; + const deadline = std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ + .clock = .awake, + .raw = .fromMilliseconds(fetch_timeout_ms), + }); + const version = if (request_auth.credential != null) + version_lookup.resolve(alloc, .codex, cancel_flag, deadline) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .failure = .{ + .category = if (err == error.Cancelled) .cancellation else .transport, + .retryable = err != error.Cancelled, + } }; + } + else + null; + const request_url = modelsUrl(alloc, version) 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, @@ -88,10 +105,7 @@ fn fetchCatalogForProvider( FetchResponse, alloc, cancel_flag, - std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ - .clock = .awake, - .raw = .fromMilliseconds(fetch_timeout_ms), - }), + deadline, &operation, ) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; @@ -203,16 +217,17 @@ const FetchOperation = struct { } }; -fn modelsUrl(alloc: std.mem.Allocator) ![]u8 { +fn modelsUrl(alloc: std.mem.Allocator, version: ?versions.Version) ![]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.InvalidE2ECodexModelsEndpoint; } + const compatible = version orelse return alloc.dupe(u8, base); const separator: u8 = if (std.mem.findScalar(u8, base, '?') == null) '?' else '&'; return std.fmt.allocPrint( alloc, "{s}{c}client_version={s}", - .{ base, separator, protocol_client_version }, + .{ base, separator, compatible.slice() }, ); } @@ -349,11 +364,11 @@ test "Codex catalog parser keeps visible API models and live capabilities" { try std.testing.expectEqual(@as(u32, 272_000), model.context_window); } -test "Codex catalog URL uses the live-validated protocol compatibility version" { - const url = try modelsUrl(std.testing.allocator); +test "Codex catalog URL uses the resolved compatibility version" { + const url = try modelsUrl(std.testing.allocator, versions.Version.parse("0.999.1").?); defer std.testing.allocator.free(url); - try std.testing.expect(std.mem.find(u8, url, "client_version=0.148.0") != null); - try std.testing.expect(std.mem.find(u8, url, "client_version=0.0.4") == null); + try std.testing.expect(std.mem.find(u8, url, "client_version=0.999.1") != null); + try std.testing.expect(std.mem.find(u8, url, "client_version=0.148.0") == null); } test "host-managed Codex catalog auth carries no local headers" { diff --git a/src/gateway/provider_versions.zig b/src/gateway/provider_versions.zig new file mode 100644 index 000000000..66e763170 --- /dev/null +++ b/src/gateway/provider_versions.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const versions = @import("../core/gateway/provider_versions.zig"); +const io_mod = @import("../core/shared/io.zig"); +const debug_trace = @import("../core/shared/debug_trace.zig"); +const gateway_client = @import("client.zig"); + +const Allocator = std.mem.Allocator; +const lookup_timeout_ms = 3000; +const max_response_bytes = 64 * 1024; + +pub fn resolve( + alloc: Allocator, + provider: versions.Provider, + cancel_flag: *std.atomic.Value(bool), + outer_deadline: ?std.Io.Clock.Timestamp, +) versions.Error!versions.Version { + if (cancel_flag.load(.seq_cst)) return error.Cancelled; + const override_name = switch (provider) { + .codex => "FX_E2E_CODEX_CLIENT_VERSION", + .grok => "FX_E2E_GROK_CLIENT_VERSION", + }; + if (io_mod.getenv(override_name)) |value| { + return versions.Version.parse(value) orelse error.ProviderVersionUnavailable; + } + var context = LookupContext{ .cancel_flag = cancel_flag, .outer_deadline = outer_deadline }; + return versions.resolve(alloc, provider, .{ .context = &context, .fetch = fetch }); +} + +const LookupContext = struct { + cancel_flag: *std.atomic.Value(bool), + outer_deadline: ?std.Io.Clock.Timestamp, +}; + +fn fetch(raw: ?*anyopaque, alloc: Allocator, provider: versions.Provider) versions.Error!versions.Version { + const context: *LookupContext = @ptrCast(@alignCast(raw.?)); + const override_name = switch (provider) { + .codex => "FX_E2E_CODEX_VERSION_URL", + .grok => "FX_E2E_GROK_VERSION_URL", + }; + const url = if (io_mod.getenv(override_name)) |value| blk: { + if (!gateway_client.isLoopbackHttpUrl(value)) return error.ProviderVersionUnavailable; + break :blk value; + } else switch (provider) { + .codex => "https://registry.npmjs.org/@openai/codex/latest", + .grok => "https://x.ai/cli/stable", + }; + var deadline = std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ + .clock = .awake, + .raw = .fromMilliseconds(lookup_timeout_ms), + }); + if (context.outer_deadline) |outer| { + if (std.Io.Clock.Timestamp.compare(outer, .lt, deadline)) deadline = outer; + } + var operation = LookupOperation{ .alloc = alloc, .url = url }; + var response = gateway_client.runBoundedHttpOperation(Response, alloc, context.cancel_flag, deadline, &operation) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + if (err == error.Cancelled) return error.Cancelled; + debug_trace.logf("models", "provider version lookup failed provider={t} err={s}", .{ provider, @errorName(err) }); + return error.ProviderVersionUnavailable; + }; + defer response.deinit(alloc); + if (response.status != .ok) { + debug_trace.logf("models", "provider version lookup rejected provider={t} status={d}", .{ provider, @intFromEnum(response.status) }); + return error.ProviderVersionUnavailable; + } + return parseResponse(alloc, provider, response.body) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + debug_trace.logf("models", "provider version metadata invalid provider={t}", .{provider}); + return error.ProviderVersionUnavailable; + }; +} + +const Response = struct { + status: std.http.Status, + body: []u8, + + pub fn deinit(self: *Response, alloc: Allocator) void { + alloc.free(self.body); + self.* = undefined; + } +}; + +const LookupOperation = struct { + alloc: Allocator, + url: []const u8, + + pub fn run(self: *LookupOperation) !Response { + var client: std.http.Client = .{ .allocator = self.alloc, .io = io_mod.getIo() }; + defer client.deinit(); + const buffer = try self.alloc.alloc(u8, max_response_bytes + 1); + defer self.alloc.free(buffer); + var writer = std.Io.Writer.fixed(buffer); + const result = try client.fetch(.{ + .location = .{ .url = self.url }, + .method = .GET, + .headers = .{ + .user_agent = .{ .override = gateway_client.user_agent }, + .accept_encoding = .omit, + }, + .redirect_behavior = .unhandled, + .response_writer = &writer, + }); + if (writer.buffered().len > max_response_bytes) return error.ProviderVersionResponseTooLarge; + return .{ .status = result.status, .body = try self.alloc.dupe(u8, writer.buffered()) }; + } +}; + +fn parseResponse(alloc: Allocator, provider: versions.Provider, body: []const u8) !versions.Version { + if (body.len > max_response_bytes) return error.ProviderVersionResponseTooLarge; + if (provider == .grok) return versions.Version.parse(body) orelse error.InvalidProviderVersion; + const Release = struct { version: []const u8 }; + const parsed = try std.json.parseFromSlice(Release, alloc, body, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + return versions.Version.parse(parsed.value.version) orelse error.InvalidProviderVersion; +} + +test "provider version metadata parses the two upstream release formats" { + const codex = try parseResponse(std.testing.allocator, .codex, "{\"version\":\"0.153.1\",\"name\":\"@openai/codex\"}"); + try std.testing.expectEqualStrings("0.153.1", codex.slice()); + const grok = try parseResponse(std.testing.allocator, .grok, "1.0.13\n"); + try std.testing.expectEqualStrings("1.0.13", grok.slice()); + try std.testing.expectError(error.InvalidProviderVersion, parseResponse(std.testing.allocator, .codex, "{\"version\":\"bad\"}")); + try std.testing.expectError(error.InvalidProviderVersion, parseResponse(std.testing.allocator, .grok, "1.0.13\nHeader: injected")); +} diff --git a/src/gateway/xai_grok.zig b/src/gateway/xai_grok.zig index 5450d6333..6857f2d09 100644 --- a/src/gateway/xai_grok.zig +++ b/src/gateway/xai_grok.zig @@ -12,7 +12,7 @@ const model_tool_schema = @import("../core/tooling/model_tool_schema.zig"); const Allocator = std.mem.Allocator; const endpoint = "https://cli-chat-proxy.grok.com/v1/responses"; // The proxy gates this as Grok wire compatibility; fx identifies itself separately below. -const proxy_compatibility_version = "1.0.6"; +const version_lookup = @import("provider_versions.zig"); const e2e_endpoint_env = "FX_E2E_XAI_GROK_RESPONSES_URL"; const max_error_body_bytes: usize = 256 * 1024; const max_sse_line_bytes: usize = 1024 * 1024; @@ -244,6 +244,10 @@ pub fn streamPrepared( break :endpoint override; } else endpoint; const uri = try std.Uri.parse(request_endpoint); + const compatibility = if (auth_headers.include_subscription_headers) + try version_lookup.resolve(alloc, .grok, request.cancel_flag, request.deadline) + else + null; var extra_headers_buf: [8]std.http.Header = undefined; var extra_count: usize = 0; @@ -255,8 +259,10 @@ pub fn streamPrepared( extra_headers_buf[extra_count] = .{ .name = "x-authenticateresponse", .value = "authenticate-response" }; extra_count += 1; } - extra_headers_buf[extra_count] = .{ .name = "x-grok-client-version", .value = proxy_compatibility_version }; - extra_count += 1; + if (compatibility) |*version| { + extra_headers_buf[extra_count] = .{ .name = "x-grok-client-version", .value = version.slice() }; + extra_count += 1; + } extra_headers_buf[extra_count] = .{ .name = "x-grok-client-identifier", .value = "fx" }; extra_count += 1; extra_headers_buf[extra_count] = .{ .name = "x-grok-model-override", .value = request.model }; @@ -937,6 +943,7 @@ const XaiTestEnvironment = struct { }; errdefer self.map.deinit(); try self.map.put(e2e_endpoint_env, responses_url); + try self.map.put("FX_E2E_GROK_CLIENT_VERSION", "1.0.6"); io_mod.setEnvironMap(&self.map); return self; } diff --git a/src/gateway/xai_grok_models.zig b/src/gateway/xai_grok_models.zig index 0ec969c97..ae8645316 100644 --- a/src/gateway/xai_grok_models.zig +++ b/src/gateway/xai_grok_models.zig @@ -7,6 +7,8 @@ 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 versions = @import("../core/gateway/provider_versions.zig"); +const version_lookup = @import("provider_versions.zig"); const max_catalog_models: usize = 128; const max_model_id_bytes: usize = 256; @@ -19,6 +21,8 @@ const e2e_modalities_endpoint_env = "FX_E2E_XAI_GROK_MODALITIES_URL"; pub const model_catalog_provider = model_catalog.Provider{ .fetch_fn = fetchCatalogForProvider, + .provider_id = .grok, + .refresh_interval_ms = versions.refresh_interval_ms, }; pub const cli_model_catalog_provider = gateway_provider.CliModelCatalogProvider{ @@ -82,12 +86,20 @@ fn fetchCatalogForProvider( .clock = .awake, .raw = .fromMilliseconds(fetch_timeout_ms), }); + const version = if (request_auth.include_subscription_headers) + version_lookup.resolve(alloc, .grok, cancel_flag, deadline) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .failure = catalogFetchFailure(err) }; + } + else + null; var response = fetchCatalogResponse( alloc, request_url, request_auth.credential, request_auth.account_id, request_auth.include_subscription_headers, + version, cancel_flag, deadline, ) catch |err| { @@ -104,6 +116,7 @@ fn fetchCatalogForProvider( request_auth.credential, null, false, + null, cancel_flag, deadline, ) catch |err| { @@ -165,6 +178,7 @@ const FetchOperation = struct { credential: ?[]const u8, account_id: ?[]const u8, include_subscription_headers: bool, + client_version: ?versions.Version = null, pub fn run(self: *@This()) !FetchResponse { var client: std.http.Client = .{ .allocator = self.alloc, .io = io_mod.getIo() }; @@ -182,7 +196,7 @@ const FetchOperation = struct { 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); - var extra_headers_buffer: [3]std.http.Header = undefined; + var extra_headers_buffer: [5]std.http.Header = undefined; var extra_headers_len: usize = 0; extra_headers_buffer[extra_headers_len] = .{ .name = "accept", .value = "application/json" }; extra_headers_len += 1; @@ -194,6 +208,12 @@ const FetchOperation = struct { extra_headers_buffer[extra_headers_len] = .{ .name = "x-userid", .value = account_id }; extra_headers_len += 1; } + if (self.client_version) |*version| { + extra_headers_buffer[extra_headers_len] = .{ .name = "x-grok-client-version", .value = version.slice() }; + extra_headers_len += 1; + extra_headers_buffer[extra_headers_len] = .{ .name = "x-grok-client-identifier", .value = "fx" }; + extra_headers_len += 1; + } const result = client.fetch(.{ .location = .{ .url = self.url }, .method = .GET, @@ -220,6 +240,7 @@ fn fetchCatalogResponse( credential: ?[]const u8, account_id: ?[]const u8, include_subscription_headers: bool, + client_version: ?versions.Version, cancel_flag: *std.atomic.Value(bool), deadline: std.Io.Clock.Timestamp, ) !FetchResponse { @@ -229,6 +250,7 @@ fn fetchCatalogResponse( .credential = credential, .account_id = account_id, .include_subscription_headers = include_subscription_headers, + .client_version = client_version, }; return gateway_client.runBoundedHttpOperation( FetchResponse, @@ -712,6 +734,7 @@ const CatalogEndpointEnvironment = struct { errdefer self.map.deinit(); try self.map.put(e2e_models_endpoint_env, models_url); try self.map.put(e2e_modalities_endpoint_env, modalities_url); + try self.map.put("FX_E2E_GROK_CLIENT_VERSION", "1.0.6"); io_mod.setEnvironMap(&self.map); return self; } diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index b5af94db5..f7334dd2a 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -15,7 +15,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { FX_BIN, HAS_API_KEY, REPO_ROOT, runFx } from "../evals/eval-helpers"; +import { FX_BIN, HAS_API_KEY, REPO_ROOT, runFx, providerVersionTestEnv } from "../evals/eval-helpers"; import { AUTO_EXA_SERIALIZED_TOOL_NAMES, customProviderGuidanceState, @@ -358,6 +358,7 @@ function startAcpFakeCodex(options: { const modelRequests: Array<{ path: string; authorization: string | null }> = []; const tokenRequests: Array<{ path: string; authorization: string | null }> = []; let unauthorizedResponses = options.unauthorizedResponses ?? 0; + const extraModels: string[] = []; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -370,6 +371,7 @@ function startAcpFakeCodex(options: { { slug: "gpt-5.6-sol", visibility: "list", supported_in_api: true, supported_reasoning_levels: [{ effort: "high" }], additional_speed_tiers: ["fast"], input_modalities: ["text", "image"], context_window: 272000 }, { slug: "gpt-5.6-luna", visibility: "list", supported_in_api: true, supported_reasoning_levels: [{ effort: "medium" }], additional_speed_tiers: [], input_modalities: ["text"], context_window: 272000 }, { slug: "gpt-5.4-mini", visibility: "list", supported_in_api: true, supported_reasoning_levels: [{ effort: "low" }], additional_speed_tiers: [], input_modalities: ["text"], context_window: 128000 }, + ...extraModels.map((slug) => ({ slug, visibility: "list", supported_in_api: true })), ] }); } if (path === "/token") { @@ -401,6 +403,7 @@ function startAcpFakeCodex(options: { responsesUrl: `http://127.0.0.1:${server.port}/responses`, modelsUrl: `http://127.0.0.1:${server.port}/models`, tokenUrl: `http://127.0.0.1:${server.port}/token`, + addModel(slug: string) { extraModels.push(slug); }, stop() { server.stop(true); }, }; } @@ -588,11 +591,11 @@ class AcpClient { } } const proc = nodeSpawn(FX_BIN, args, { - env: { + env: providerVersionTestEnv({ ...inheritedEnv, NO_COLOR: "1", PATH: inheritedEnv.PATH ?? "", - }, + }), cwd: opts?.cwd ?? REPO_ROOT, stdio: ["pipe", "pipe", "pipe"], }); @@ -8017,6 +8020,167 @@ describe("acp: model catalog authentication", () => { }, TIMEOUT, ); + test( + "session provider changes use Codex credentials without crossing origins", + async () => { + const root = createIsolatedRoot("fx-acp-chatgpt-route-"); + const gateway = startFakeGateway([]); + const codex = startAcpFakeCodex({ unauthorizedResponses: 1 }); + writeSeededAcpChatGptLogin(root.home, codex.accessToken); + try { + client = await AcpClient.create({ + cwd: root.workspace, + env: { + ...fakeGatewayEnv(root, gateway), + FX_E2E_OPENAI_CODEX_RESPONSES_URL: codex.responsesUrl, + FX_E2E_OPENAI_CODEX_MODELS_URL: codex.modelsUrl, + FX_E2E_CHATGPT_TOKEN_URL: codex.tokenUrl, + }, + }); + await client.request("initialize", { protocolVersion: 1 }, 1); + await client.request("session/new", { mcpServers: [] }, 2); + await client.readLine(); // consume session/update notification + + const changed = await client.request("session/set_config_option", { + configId: "provider", + value: "codex", + }, 3) as any; + expect(changed.result.configOptions.find((option: any) => option.id === "provider").currentValue) + .toBe("codex"); + expect(changed.result.configOptions.find((option: any) => option.id === "model").currentValue) + .toBe("gpt-5.6-sol"); + + const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); + expect(prompt.promptResult.result.stopReason).toBe("end_turn"); + expect(JSON.stringify(prompt.messages)).toContain("ACP_CHATGPT_RESPONSE"); + const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); + expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); + expect(codex.requests).toHaveLength(3); + expect(codex.modelRequests).toHaveLength(1); + expect(codex.requests[0]!.authorization).toBe(`Bearer ${codex.accessToken}`); + expect(codex.requests[1]!.authorization).toBe(`Bearer ${codex.refreshedAccessToken}`); + expect(codex.requests[2]!.authorization).toBe(`Bearer ${codex.refreshedAccessToken}`); + expect(codex.tokenRequests).toHaveLength(1); + for (const request of [...gateway.requests, ...gateway.modelRequests]) { + expect(request.headers.get("authorization")).not.toBe(`Bearer ${codex.accessToken}`); + } + } finally { + await client?.close(); + codex.stop(); + gateway.stop(); + rmSync(root.root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + + test("an open ACP connection refreshes subscription model options before selection", async () => { + const root = createIsolatedRoot("fx-acp-catalog-refresh-"); + const gateway = startFakeGateway([]); + const codex = startAcpFakeCodex(); + writeSeededAcpChatGptLogin(root.home, codex.accessToken); + try { + client = await AcpClient.create({ + cwd: root.workspace, + env: { + ...fakeGatewayEnv(root, gateway), + FX_E2E_OPENAI_CODEX_RESPONSES_URL: codex.responsesUrl, + FX_E2E_OPENAI_CODEX_MODELS_URL: codex.modelsUrl, + FX_E2E_CHATGPT_TOKEN_URL: codex.tokenUrl, + }, + }); + await client.request("initialize", { protocolVersion: 1 }, 1); + await client.request("session/new", { mcpServers: [] }, 2); + await client.readLine(); + await client.request("session/set_config_option", { configId: "provider", value: "codex" }, 3); + expect(codex.modelRequests).toHaveLength(1); + codex.addModel("gpt-next-fixture"); + await Bun.sleep(61_000); + const listed = await client.request("session/set_config_option", { configId: "mode", value: "code" }, 4) as any; + expect(JSON.stringify(listed.result.configOptions)).toContain("gpt-next-fixture"); + const selected = await client.request("session/set_config_option", { configId: "model", value: "gpt-next-fixture" }, 5) as any; + expect(selected.result.configOptions.find((option: any) => option.id === "model").currentValue).toBe("gpt-next-fixture"); + expect(codex.modelRequests).toHaveLength(2); + expect(client.stderr).toBe(""); + } finally { + await client?.close(); + codex.stop(); + gateway.stop(); + rmSync(root.root, { recursive: true, force: true }); + } + }, 120_000); + + test( + "session provider changes use Grok credentials with byte-identical account-stable replay", + async () => { + const root = createIsolatedRoot("fx-acp-grok-route-"); + const gateway = startFakeGateway([]); + const grok = startAcpFakeGrok({ unauthorizedResponses: 1 }); + writeSeededAcpGrokLogin(root.home, grok.accessToken); + try { + client = await AcpClient.create({ + cwd: root.workspace, + env: { + ...fakeGatewayEnv(root, gateway), + FX_E2E_XAI_GROK_RESPONSES_URL: grok.responsesUrl, + FX_E2E_XAI_GROK_MODELS_URL: grok.modelsUrl, + FX_E2E_XAI_GROK_MODALITIES_URL: grok.modalitiesUrl, + FX_E2E_GROK_TOKEN_URL: grok.tokenUrl, + FX_E2E_GROK_USERINFO_URL: grok.userinfoUrl, + }, + }); + await client.request("initialize", { protocolVersion: 1 }, 1); + await client.request("session/new", { mcpServers: [] }, 2); + await client.readLine(); + + const changed = await client.request("session/set_config_option", { + configId: "provider", + value: "grok", + }, 3) as any; + expect(changed.result.configOptions.find((option: any) => option.id === "provider").currentValue) + .toBe("grok"); + expect(changed.result.configOptions.find((option: any) => option.id === "model").currentValue) + .toBe("grok-4.20"); + + const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); + expect(prompt.promptResult.result.stopReason).toBe("end_turn"); + expect(JSON.stringify(prompt.messages)).toContain("ACP_GROK_RESPONSE"); + const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); + expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); + + expect(grok.requests).toHaveLength(3); + expect(grok.requests[0]!.body).toBe(grok.requests[1]!.body); + expect(grok.requests[0]!.conversationId).toBeTruthy(); + expect(grok.requests[0]!.conversationId).toBe(grok.requests[1]!.conversationId); + expect(grok.modelRequests.map((request) => request.path)).toEqual(["/models", "/modalities"]); + expect(grok.requests[0]!.authorization).toBe(`Bearer ${grok.accessToken}`); + expect(grok.requests[1]!.authorization).toBe(`Bearer ${grok.refreshedAccessToken}`); + expect(grok.requests[2]!.authorization).toBe(`Bearer ${grok.refreshedAccessToken}`); + for (const request of grok.requests) { + expect(request.tokenAuth).toBe("xai-grok-cli"); + expect(request.authenticateResponse).toBe("authenticate-response"); + expect(request.clientIdentifier).toBe("fx"); + expect(request.clientVersion).toBe("1.0.6"); + expect(request.modelOverride).toBe("grok-4.20"); + expect(request.grokUserId).toBe("acct_grok_acp"); + } + expect(grok.tokenRequests).toHaveLength(1); + expect(grok.tokenRequests[0]!.body).toContain("grant_type=refresh_token"); + expect(grok.userinfoRequests).toHaveLength(1); + expect(grok.userinfoRequests[0]!.authorization).toBe(`Bearer ${grok.refreshedAccessToken}`); + for (const request of [...gateway.requests, ...gateway.modelRequests]) { + expect(request.headers.get("authorization")).not.toContain("grok-acp-"); + } + } finally { + await client?.close(); + grok.stop(); + gateway.stop(); + rmSync(root.root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + }); describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { @@ -8218,131 +8382,6 @@ describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { TIMEOUT, ); - test( - "session provider changes use Codex credentials without crossing origins", - async () => { - const root = createIsolatedRoot("fx-acp-chatgpt-route-"); - const gateway = startFakeGateway([]); - const codex = startAcpFakeCodex({ unauthorizedResponses: 1 }); - writeSeededAcpChatGptLogin(root.home, codex.accessToken); - try { - client = await AcpClient.create({ - cwd: root.workspace, - env: { - ...fakeGatewayEnv(root, gateway), - FX_E2E_OPENAI_CODEX_RESPONSES_URL: codex.responsesUrl, - FX_E2E_OPENAI_CODEX_MODELS_URL: codex.modelsUrl, - FX_E2E_CHATGPT_TOKEN_URL: codex.tokenUrl, - }, - }); - await client.request("initialize", { protocolVersion: 1 }, 1); - await client.request("session/new", { mcpServers: [] }, 2); - await client.readLine(); // consume session/update notification - - const changed = await client.request("session/set_config_option", { - configId: "provider", - value: "codex", - }, 3) as any; - expect(changed.result.configOptions.find((option: any) => option.id === "provider").currentValue) - .toBe("codex"); - expect(changed.result.configOptions.find((option: any) => option.id === "model").currentValue) - .toBe("gpt-5.6-sol"); - - const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); - expect(prompt.promptResult.result.stopReason).toBe("end_turn"); - expect(JSON.stringify(prompt.messages)).toContain("ACP_CHATGPT_RESPONSE"); - const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); - expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); - expect(codex.requests).toHaveLength(3); - expect(codex.modelRequests).toHaveLength(1); - expect(codex.requests[0]!.authorization).toBe(`Bearer ${codex.accessToken}`); - expect(codex.requests[1]!.authorization).toBe(`Bearer ${codex.refreshedAccessToken}`); - expect(codex.requests[2]!.authorization).toBe(`Bearer ${codex.refreshedAccessToken}`); - expect(codex.tokenRequests).toHaveLength(1); - for (const request of [...gateway.requests, ...gateway.modelRequests]) { - expect(request.headers.get("authorization")).not.toBe(`Bearer ${codex.accessToken}`); - } - } finally { - await client?.close(); - codex.stop(); - gateway.stop(); - rmSync(root.root, { recursive: true, force: true }); - } - }, - TIMEOUT, - ); - - test( - "session provider changes use Grok credentials with byte-identical account-stable replay", - async () => { - const root = createIsolatedRoot("fx-acp-grok-route-"); - const gateway = startFakeGateway([]); - const grok = startAcpFakeGrok({ unauthorizedResponses: 1 }); - writeSeededAcpGrokLogin(root.home, grok.accessToken); - try { - client = await AcpClient.create({ - cwd: root.workspace, - env: { - ...fakeGatewayEnv(root, gateway), - FX_E2E_XAI_GROK_RESPONSES_URL: grok.responsesUrl, - FX_E2E_XAI_GROK_MODELS_URL: grok.modelsUrl, - FX_E2E_XAI_GROK_MODALITIES_URL: grok.modalitiesUrl, - FX_E2E_GROK_TOKEN_URL: grok.tokenUrl, - FX_E2E_GROK_USERINFO_URL: grok.userinfoUrl, - }, - }); - await client.request("initialize", { protocolVersion: 1 }, 1); - await client.request("session/new", { mcpServers: [] }, 2); - await client.readLine(); - - const changed = await client.request("session/set_config_option", { - configId: "provider", - value: "grok", - }, 3) as any; - expect(changed.result.configOptions.find((option: any) => option.id === "provider").currentValue) - .toBe("grok"); - expect(changed.result.configOptions.find((option: any) => option.id === "model").currentValue) - .toBe("grok-4.20"); - - const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); - expect(prompt.promptResult.result.stopReason).toBe("end_turn"); - expect(JSON.stringify(prompt.messages)).toContain("ACP_GROK_RESPONSE"); - const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); - expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); - - expect(grok.requests).toHaveLength(3); - expect(grok.requests[0]!.body).toBe(grok.requests[1]!.body); - expect(grok.requests[0]!.conversationId).toBeTruthy(); - expect(grok.requests[0]!.conversationId).toBe(grok.requests[1]!.conversationId); - expect(grok.modelRequests.map((request) => request.path)).toEqual(["/models", "/modalities"]); - expect(grok.requests[0]!.authorization).toBe(`Bearer ${grok.accessToken}`); - expect(grok.requests[1]!.authorization).toBe(`Bearer ${grok.refreshedAccessToken}`); - expect(grok.requests[2]!.authorization).toBe(`Bearer ${grok.refreshedAccessToken}`); - for (const request of grok.requests) { - expect(request.tokenAuth).toBe("xai-grok-cli"); - expect(request.authenticateResponse).toBe("authenticate-response"); - expect(request.clientIdentifier).toBe("fx"); - expect(request.clientVersion).toBe("1.0.6"); - expect(request.modelOverride).toBe("grok-4.20"); - expect(request.grokUserId).toBe("acct_grok_acp"); - } - expect(grok.tokenRequests).toHaveLength(1); - expect(grok.tokenRequests[0]!.body).toContain("grant_type=refresh_token"); - expect(grok.userinfoRequests).toHaveLength(1); - expect(grok.userinfoRequests[0]!.authorization).toBe(`Bearer ${grok.refreshedAccessToken}`); - for (const request of [...gateway.requests, ...gateway.modelRequests]) { - expect(request.headers.get("authorization")).not.toContain("grok-acp-"); - } - } finally { - await client?.close(); - grok.stop(); - gateway.stop(); - rmSync(root.root, { recursive: true, force: true }); - } - }, - TIMEOUT, - ); - test( "session/prompt returns response with stopReason", async () => { diff --git a/tests/e2e/tmux-helpers.ts b/tests/e2e/tmux-helpers.ts index aa50bbd77..bfe38038c 100644 --- a/tests/e2e/tmux-helpers.ts +++ b/tests/e2e/tmux-helpers.ts @@ -10,7 +10,7 @@ import { execFileSync, execSync } from "node:child_process"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { FX_BIN, REPO_ROOT } from "../evals/eval-helpers"; +import { FX_BIN, REPO_ROOT, providerVersionTestEnv } from "../evals/eval-helpers"; let sessionCounter = 0; @@ -456,7 +456,7 @@ export class TmuxSession { const { cmd = FX_BIN, cwd = REPO_ROOT, - env = {}, + env: requestedEnv = {}, width = 120, height = 40, stderrPath, @@ -466,6 +466,7 @@ export class TmuxSession { isolated = false, socketName, } = opts ?? {}; + const env = providerVersionTestEnv(requestedEnv); if ( minimumHistoryLines !== undefined && diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index 5223365e9..000e21680 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -14,7 +14,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { FX_BIN, REPO_ROOT, runFx } from "../evals/eval-helpers"; +import { FX_BIN, REPO_ROOT, runFx, providerVersionTestEnv } from "../evals/eval-helpers"; import { readTapeFrames } from "./render-lab/tape"; import { FAKE_GATEWAY_MODEL, @@ -797,7 +797,7 @@ async function runGrokLoginWithBrowser( } const proc = nodeSpawn(FX_BIN, ["login", "grok"], { cwd: REPO_ROOT, - env: childEnv, + env: providerVersionTestEnv(childEnv), stdio: [authorizationCode ? "pipe" : "ignore", "pipe", "pipe"], }); let stdout = ""; @@ -878,7 +878,7 @@ async function runCodexLoginWithBrowser( } const proc = nodeSpawn(FX_BIN, ["login", "codex"], { cwd: REPO_ROOT, - env: childEnv, + env: providerVersionTestEnv(childEnv), stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -5306,3 +5306,160 @@ for (const scenario of [ 60_000, ); } + +tmuxTest("Codex discovers upstream versions and refreshes models in an open session without a CLI", async () => { + home = mkdtempSync(join(tmpdir(), "fx-codex-catalog-version-")); + stderrPath = join(home, "stderr.log"); + gateway = startFakeGateway([]); + writeSeededChatGptLogin(home); + writeFileSync(join(home, ".fx", "settings.json"), JSON.stringify({ + provider: "codex", + models: { codex: "gpt-5.6-luna" }, + }) + "\n", { mode: 0o600 }); + const versions: Array = []; + const releaseHeaders: Headers[] = []; + let latest = "0.999.1"; + const catalog = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/version") { + releaseHeaders.push(request.headers); + return Response.json({ version: latest }); + } + const version = new URL(request.url).searchParams.get("client_version"); + versions.push(version); + const ids = ["gpt-5.6-luna"]; + if (version === latest) ids.push("gpt-6-astra"); + if (version === "1.0.0") ids.push("future-release"); + return Response.json({ models: ids.map((slug) => ({ + slug, + visibility: "list", + supported_in_api: true, + supported_reasoning_levels: [{ effort: "high" }, { effort: "ultra" }], + input_modalities: ["text", "image"], + context_window: 272000, + })) }); + }, + }); + try { + const env = { + HOME: home, + PATH: "/usr/bin:/bin", + FX_MODEL: undefined, + FX_DISABLE_KEYCHAIN: "1", + FX_AUTO_UPGRADE: "0", + FX_SOUND: "0", + FX_E2E_OPENAI_CODEX_MODELS_URL: `http://127.0.0.1:${catalog.port}/models`, + FX_E2E_CODEX_VERSION_URL: `http://127.0.0.1:${catalog.port}/version`, + FX_E2E_CODEX_CLIENT_VERSION: undefined, + }; + const listed = await runFx(["models", "--json"], { env, timeoutMs: TIMEOUT }); + expect(listed.code, listed.stderr).toBe(0); + const ids = JSON.parse(listed.stdout).models.map((model: { id: string }) => model.id); + expect(ids).toContain("gpt-6-astra"); + expect(listed.stderr).toBe(""); + + session = await startFx(home, stderrPath, gateway, undefined, undefined, env); + await session.waitForComposer(TIMEOUT); + await session.sendText("/model"); + await session.waitForText("gpt-6-astra", TIMEOUT); + expect(releaseHeaders).toHaveLength(1); + await session.sendKeys("Escape"); + latest = "1.0.0"; + await Bun.sleep(61_000); + await session.sendText("/model"); + await session.waitForText("future-release", TIMEOUT); + expect(releaseHeaders).toHaveLength(2); + expect(versions.at(-1)).toBe("1.0.0"); + for (const headers of releaseHeaders) { + for (const name of ["authorization", "cookie", "chatgpt-account-id"]) { + expect(headers.get(name)).toBeNull(); + } + } + expect(versions.length).toBeGreaterThanOrEqual(2); + expect(gateway.requests).toHaveLength(0); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + } finally { + catalog.stop(true); + } +}, 120_000); + +test("Grok refreshes upstream versions for catalogs and responses and survives lookup failures", async () => { + home = mkdtempSync(join(tmpdir(), "fx-grok-version-discovery-")); + const grok = startFakeGrokOAuth(); + let latest = "1.999.1"; + let failure: "none" | "unavailable" | "malformed" | "slow" = "none"; + const releaseHeaders: Headers[] = []; + const releases = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + releaseHeaders.push(request.headers); + if (failure === "slow") await Bun.sleep(5000); + if (failure === "unavailable") return new Response("unavailable", { status: 503 }); + return new Response(failure === "malformed" ? "1.2.3\r\nInjected: bad" : latest); + }, + }); + try { + writeSeededGrokLogin(home, grok.initialAccessToken); + writeFileSync(join(home, ".fx", "settings.json"), JSON.stringify({ + provider: "grok", models: { grok: "grok-4.20" }, + }) + "\n", { mode: 0o600 }); + const env = { + HOME: home, + PATH: "/usr/bin:/bin", + FX_DISABLE_KEYCHAIN: "1", + FX_AUTO_UPGRADE: "0", + FX_SOUND: "0", + ...grok.env, + FX_E2E_GROK_VERSION_URL: `http://127.0.0.1:${releases.port}/stable`, + FX_E2E_GROK_CLIENT_VERSION: undefined, + }; + const cachePath = join(home, ".fx", "provider-versions", "grok.json"); + const expireCache = () => { + const cached = JSON.parse(readFileSync(cachePath, "utf8")); + cached.checked_at_ms = 0; + writeFileSync(cachePath, JSON.stringify(cached)); + }; + const first = await runFx(["models", "--json"], { env }); + expect(first.code, first.stderr).toBe(0); + expect(releaseHeaders).toHaveLength(1); + expect(grok.requests.find((request) => request.path === "/v1/models")?.clientVersion).toBe(latest); + expect(grok.requests.find((request) => request.path === "/v1/language-models")?.clientVersion).toBeNull(); + + latest = "2.0.0"; + expireCache(); + const asked = await runFx(["ask", "--json", "--auto", "--no-save", "Reply briefly."], { env }); + expect(asked.code, asked.stderr).toBe(0); + expect(asked.stdout).toContain("GROK_DIRECT_RESPONSE"); + expect(grok.requests.find((request) => request.path === "/v1/responses")?.clientVersion).toBe(latest); + expect(releaseHeaders).toHaveLength(2); + + for (const mode of ["unavailable", "malformed"] as const) { + failure = mode; + expireCache(); + const result = await runFx(["models", "--json"], { env }); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(cachePath, "utf8")).version).toBe("2.0.0"); + } + rmSync(cachePath); + const malformed = await runFx(["models", "--json"], { env }); + expect(malformed.code).toBe(1); + expect(existsSync(cachePath)).toBe(false); + expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); + failure = "slow"; + const started = Date.now(); + const timedOut = await runFx(["models", "--json"], { env, timeoutMs: 8000 }); + expect(timedOut.code).toBe(1); + expect(Date.now() - started).toBeLessThan(6500); + for (const headers of releaseHeaders) { + for (const name of ["authorization", "cookie", "x-userid", "x-xai-token-auth"]) { + expect(headers.get(name)).toBeNull(); + } + } + } finally { + grok.stop(); + releases.stop(true); + } +}, 30_000); diff --git a/tests/evals/eval-helpers.ts b/tests/evals/eval-helpers.ts index e88b8b36a..f82e5c53a 100644 --- a/tests/evals/eval-helpers.ts +++ b/tests/evals/eval-helpers.ts @@ -15,6 +15,17 @@ import { join, resolve } from "node:path"; export const FX_BIN = resolve(import.meta.dirname, "../../zig-out/bin/fx"); export const REPO_ROOT = resolve(import.meta.dirname, "../.."); +export function providerVersionTestEnv(env: Record): Record { + const result = { ...env }; + if (env.FX_E2E_OPENAI_CODEX_MODELS_URL && !env.FX_E2E_CODEX_VERSION_URL && !env.FX_E2E_CODEX_CLIENT_VERSION) { + result.FX_E2E_CODEX_CLIENT_VERSION = "0.153.0"; + } + if ((env.FX_E2E_XAI_GROK_MODELS_URL || env.FX_E2E_XAI_GROK_RESPONSES_URL) && !env.FX_E2E_GROK_VERSION_URL && !env.FX_E2E_GROK_CLIENT_VERSION) { + result.FX_E2E_GROK_CLIENT_VERSION = "1.0.6"; + } + return result; +} + export const EVAL_MODELS = [ "anthropic/claude-sonnet-4.6", "xai/grok-4.20-multi-agent-beta", @@ -483,7 +494,7 @@ export async function runFx( } } const child = nodeSpawn(FX_BIN, args, { - env, + env: providerVersionTestEnv(env), cwd: cwd ?? REPO_ROOT, stdio: ["pipe", "pipe", "pipe"], });