diff --git a/src/builtins/gateway.zig b/src/builtins/gateway.zig index 69bd279f1..d6aae7ca8 100644 --- a/src/builtins/gateway.zig +++ b/src/builtins/gateway.zig @@ -23,6 +23,7 @@ const credential_authority = @import("../core/auth/credential_authority.zig"); const model_capabilities = @import("../core/config/model_capabilities.zig"); const vercel_model_policy = @import("../gateway/vercel_model_policy.zig"); const model_catalog = @import("../core/gateway/model_catalog.zig"); +const catalog_disk_cache = @import("../core/gateway/catalog_disk_cache.zig"); const output_contracts = @import("../core/output/output_contracts.zig"); const shared_types = @import("../core/shared/types.zig"); const session_usage = @import("../core/session/session_usage.zig"); @@ -2282,6 +2283,112 @@ fn fetchModelCatalogForView( return parseModelCatalogForView(alloc, json_text, view); } +const e2e_models_url_env = "FX_E2E_GATEWAY_MODELS_URL"; + +const model_catalog_cache_config = catalog_disk_cache.Config{ + .file_prefix = "gateway-models", + // The catalog schema this binary parses can change between releases, so + // gate cache entries on the fx build that wrote them. + .version = gateway_client.user_agent, + .max_body_bytes = 8 * 1024 * 1024, +}; + +/// The cache is bypassed whenever a loopback endpoint override is active: +/// e2e runs must observe every catalog request, and must not perform durable +/// writes outside the fixture sandbox. +fn modelCatalogCacheEnabled() bool { + return io_mod.getenv(e2e_models_url_env) == null and + io_mod.getenv(base_url_env) == null; +} + +/// One cache slot per catalog identity: credential source, team, account (or +/// the credential itself when no stable account id exists), and the resolved +/// URL. The material is hashed before it reaches the file system. +fn modelCatalogCachePartition( + alloc: Allocator, + access: credentials.CatalogAccess, + url: []const u8, +) ![]u8 { + const source_label = if (access.credentialSource()) |source| @tagName(source) else "public"; + const team = access.teamContext() orelse ""; + const account = access.accountId() orelse (access.authorizationCredential() orelse ""); + return std.fmt.allocPrint( + alloc, + "{s}\x00{s}\x00{s}\x00{s}", + .{ source_label, team, account, url }, + ); +} + +fn modelCatalogCachePath( + alloc: Allocator, + access: credentials.CatalogAccess, + url: []const u8, +) ?[]u8 { + if (!modelCatalogCacheEnabled()) return null; + const partition = modelCatalogCachePartition(alloc, access, url) catch return null; + defer alloc.free(partition); + return catalog_disk_cache.cachePath(alloc, model_catalog_cache_config, partition) catch null; +} + +/// Stores a fetched catalog body only after it parses with the same rules as +/// every consumer, so a cached body can never be weaker than a fetched one. +/// The response is already in hand, so all failures here are advisory. +fn storeValidatedModelCatalogBody(alloc: Allocator, path: []const u8, body: []const u8) void { + var catalog = parseModelCatalogForView(alloc, body, .full) catch |err| { + debug_trace.logf( + "catalog", + "Gateway model catalog cache outcome=store_skipped error={s}", + .{@errorName(err)}, + ); + return; + }; + freeModelCatalog(alloc, &catalog); + catalog_disk_cache.store( + alloc, + model_catalog_cache_config, + path, + body, + io_mod.milliTimestamp(), + ) catch |err| { + debug_trace.logf( + "catalog", + "Gateway model catalog cache outcome=store_failed error={s}", + .{@errorName(err)}, + ); + return; + }; + debug_trace.logf("catalog", "Gateway model catalog cache outcome=stored", .{}); +} + +test "gateway model catalog cache partitions separate identity, team, and URL" { + const alloc = std.testing.allocator; + const key_access: credentials.CatalogAccess = .{ .authenticated = .{ + .source = .gateway_api_key, + .credential = "vck_test", + .team_context = null, + } }; + const base = try modelCatalogCachePartition(alloc, key_access, "https://gateway/v1/models"); + defer alloc.free(base); + const other_url = try modelCatalogCachePartition(alloc, key_access, "https://gateway/v2/models"); + defer alloc.free(other_url); + try std.testing.expect(!std.mem.eql(u8, base, other_url)); + + const other_key: credentials.CatalogAccess = .{ .authenticated = .{ + .source = .gateway_api_key, + .credential = "vck_other", + .team_context = null, + } }; + const other_credential = try modelCatalogCachePartition(alloc, other_key, "https://gateway/v1/models"); + defer alloc.free(other_credential); + try std.testing.expect(!std.mem.eql(u8, base, other_credential)); + + const public_access: credentials.CatalogAccess = .{ .public_only = .no_credential }; + const public_partition = try modelCatalogCachePartition(alloc, public_access, "https://gateway/v1/models"); + defer alloc.free(public_partition); + try std.testing.expect(std.mem.startsWith(u8, public_partition, "public")); + try std.testing.expect(!std.mem.eql(u8, base, public_partition)); +} + fn fetchModelCatalogResponse( alloc: std.mem.Allocator, access: credentials.CatalogAccess, @@ -2302,12 +2409,34 @@ fn fetchModelCatalogResponse( ); defer alloc.free(model_catalog_url); + const cache_path = modelCatalogCachePath(alloc, access, model_catalog_url); + defer if (cache_path) |cache_file| alloc.free(cache_file); + if (cache_path) |cache_file| { + if (catalog_disk_cache.loadFresh( + alloc, + model_catalog_cache_config, + cache_file, + io_mod.milliTimestamp(), + )) |body| { + debug_trace.logf("catalog", "Gateway model catalog cache outcome=hit", .{}); + return .{ .success = body }; + } + debug_trace.logf("catalog", "Gateway model catalog cache outcome=miss", .{}); + } + const api_key = access.authorizationCredential(); const gateway_team = modelCatalogHeaderTeam(access); - return if (cancel_flag) |flag| - gateway_client.fetchGatewayJsonCancellable(alloc, api_key, gateway_team, model_catalog_url, flag) + const result = if (cancel_flag) |flag| + try gateway_client.fetchGatewayJsonCancellable(alloc, api_key, gateway_team, model_catalog_url, flag) else - gateway_client.fetchGatewayJson(alloc, api_key, gateway_team, model_catalog_url); + try gateway_client.fetchGatewayJson(alloc, api_key, gateway_team, model_catalog_url); + if (cache_path) |cache_file| { + switch (result) { + .success => |body| storeValidatedModelCatalogBody(alloc, cache_file, body), + .http_status => {}, + } + } + return result; } fn modelCatalogTeamPath( diff --git a/src/core/gateway/catalog_disk_cache.zig b/src/core/gateway/catalog_disk_cache.zig new file mode 100644 index 000000000..dc8849516 --- /dev/null +++ b/src/core/gateway/catalog_disk_cache.zig @@ -0,0 +1,219 @@ +//! Shared disk cache for provider model catalogs. +//! +//! Each provider stores the raw catalog response body (or a provider-defined +//! combination of bodies) in one versioned JSON envelope per partition under +//! the profile cache directory. The cache is strictly best effort: every read +//! or decode failure is a miss and the provider falls back to its network +//! fetch. Providers own three things this module cannot decide for them: +//! +//! - the partition material (which identity a catalog may be shared across); +//! - validation of a loaded body, using the same rules as endpoint responses; +//! - whether caching is enabled at all (e2e endpoint overrides disable it). + +const std = @import("std"); +const io_mod = @import("../shared/io.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); +const secret = @import("../auth/secret.zig"); + +const cache_schema_version: u32 = 1; +const envelope_overhead_bytes: usize = 4096; + +pub const default_ttl_ms: i64 = 6 * std.time.ms_per_hour; + +pub const Config = struct { + /// Cache file prefix, e.g. "codex-models". + file_prefix: []const u8, + /// Provider format version; a mismatch invalidates the entry. Providers + /// with a protocol version use it, others use the fx build version. + version: []const u8, + ttl_ms: i64 = default_ttl_ms, + max_body_bytes: usize, +}; + +const Envelope = struct { + schema_version: u32, + provider_version: []const u8, + fetched_at_ms: i64, + body: []const u8, +}; + +/// Builds the cache file path for one partition. The partition material is +/// hashed so identities never appear in file names and any byte sequence is +/// path safe. Fails when no usable HOME is available. +pub fn cachePath(alloc: std.mem.Allocator, config: Config, partition: []const u8) ![]u8 { + const home = io_mod.getenv("HOME") orelse return error.CatalogCacheUnavailable; + if (home.len == 0 or !std.fs.path.isAbsolute(home)) return error.CatalogCacheUnavailable; + const cache_dir = try profile_paths.cacheDir(alloc, home); + defer alloc.free(cache_dir); + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(partition, &digest, .{}); + const partition_hash = std.fmt.bytesToHex(digest[0..8].*, .lower); + const file_name = try std.fmt.allocPrint( + alloc, + "{s}-{s}.json", + .{ config.file_prefix, partition_hash }, + ); + defer alloc.free(file_name); + return std.fs.path.join(alloc, &.{ cache_dir, file_name }); +} + +/// Best-effort read of a cached body. Any failure — missing file, oversized +/// file, malformed envelope, schema or provider-version mismatch, stale or +/// future timestamp — is a miss, never an error. +pub fn loadFresh(alloc: std.mem.Allocator, config: Config, path: []const u8, now_ms: i64) ?[]u8 { + var file = io_mod.openExistingReadOnlyRegularFile( + std.Io.Dir.cwd(), + path, + .no_follow, + ) catch return null; + defer file.close(io_mod.getIo()); + const data = io_mod.readFileToEnd( + alloc, + &file, + config.max_body_bytes + envelope_overhead_bytes, + ) catch return null; + defer secret.zeroAndFree(alloc, data); + var parsed = std.json.parseFromSlice(Envelope, alloc, data, .{ + .ignore_unknown_fields = true, + }) catch return null; + defer parsed.deinit(); + const envelope = parsed.value; + if (envelope.schema_version != cache_schema_version) return null; + if (!std.mem.eql(u8, envelope.provider_version, config.version)) return null; + if (envelope.fetched_at_ms > now_ms) return null; + if (now_ms - envelope.fetched_at_ms >= config.ttl_ms) return null; + if (envelope.body.len == 0 or envelope.body.len > config.max_body_bytes) return null; + return alloc.dupe(u8, envelope.body) catch null; +} + +pub fn store( + alloc: std.mem.Allocator, + config: Config, + path: []const u8, + body: []const u8, + fetched_at_ms: i64, +) !void { + if (body.len == 0 or body.len > config.max_body_bytes) return error.CatalogCacheBodyTooLarge; + const parent = std.fs.path.dirname(path) orelse return error.CatalogCacheUnavailable; + try io_mod.makeDirRecursive(parent); + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + try std.json.Stringify.value(Envelope{ + .schema_version = cache_schema_version, + .provider_version = config.version, + .fetched_at_ms = fetched_at_ms, + .body = body, + }, .{}, &out.writer); + try io_mod.writeFileAtomic(alloc, path, out.written()); +} + +const test_config = Config{ + .file_prefix = "test-models", + .version = "9.9.9", + .max_body_bytes = 1024, +}; + +fn testCachePath(alloc: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 { + const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(root); + return std.fs.path.join(alloc, &.{ root, "cache", "test-models-entry.json" }); +} + +test "catalog disk cache round-trips a fresh body" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testCachePath(alloc, &tmp); + defer alloc.free(path); + + const stored_at_ms: i64 = 1_000_000; + try store(alloc, test_config, path, "{\"models\":[]}", stored_at_ms); + const loaded = loadFresh(alloc, test_config, path, stored_at_ms + 1) orelse + return error.TestExpectedCacheHit; + defer alloc.free(loaded); + try std.testing.expectEqualStrings("{\"models\":[]}", loaded); +} + +test "catalog disk cache misses on stale, future, or rewritten entries" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testCachePath(alloc, &tmp); + defer alloc.free(path); + + const stored_at_ms: i64 = 1_000_000; + try store(alloc, test_config, path, "{}", stored_at_ms); + + // Exactly at the TTL boundary and beyond: stale. + try std.testing.expectEqual( + @as(?[]u8, null), + loadFresh(alloc, test_config, path, stored_at_ms + test_config.ttl_ms), + ); + // A fetch timestamp in the future is rejected, not trusted. + try std.testing.expectEqual( + @as(?[]u8, null), + loadFresh(alloc, test_config, path, stored_at_ms - 1), + ); + // A different provider version invalidates the entry. + var other_version = test_config; + other_version.version = "0.0.1"; + try std.testing.expectEqual( + @as(?[]u8, null), + loadFresh(alloc, other_version, path, stored_at_ms + 1), + ); + // A schema-version bump invalidates the entry. + const wrong_schema = try std.fmt.allocPrint( + alloc, + "{{\"schema_version\":{d},\"provider_version\":\"{s}\",\"fetched_at_ms\":{d},\"body\":\"{{}}\"}}", + .{ cache_schema_version + 1, test_config.version, stored_at_ms }, + ); + defer alloc.free(wrong_schema); + try io_mod.writeFileAtomic(alloc, path, wrong_schema); + try std.testing.expectEqual( + @as(?[]u8, null), + loadFresh(alloc, test_config, path, stored_at_ms + 1), + ); + // Malformed JSON is a miss, not an error. + try io_mod.writeFileAtomic(alloc, path, "not json"); + try std.testing.expectEqual( + @as(?[]u8, null), + loadFresh(alloc, test_config, path, stored_at_ms + 1), + ); + // A missing file is a miss. + const missing = try std.fs.path.join(alloc, &.{ std.fs.path.dirname(path).?, "absent.json" }); + defer alloc.free(missing); + try std.testing.expectEqual( + @as(?[]u8, null), + loadFresh(alloc, test_config, missing, stored_at_ms + 1), + ); +} + +test "catalog disk cache store rejects empty and oversized bodies" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testCachePath(alloc, &tmp); + defer alloc.free(path); + + try std.testing.expectError( + error.CatalogCacheBodyTooLarge, + store(alloc, test_config, path, "", 0), + ); + const oversized = try alloc.alloc(u8, test_config.max_body_bytes + 1); + defer alloc.free(oversized); + @memset(oversized, 'a'); + try std.testing.expectError( + error.CatalogCacheBodyTooLarge, + store(alloc, test_config, path, oversized, 0), + ); +} + +test "catalog disk cache partitions map to distinct stable file names" { + // cachePath needs HOME from the process environ, which unit tests do not + // provide, so exercise the partition hashing directly. + var digest_a: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + var digest_b: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash("account-a", &digest_a, .{}); + std.crypto.hash.sha2.Sha256.hash("account-b", &digest_b, .{}); + try std.testing.expect(!std.mem.eql(u8, digest_a[0..8], digest_b[0..8])); +} diff --git a/src/core/shared/profile_paths.zig b/src/core/shared/profile_paths.zig index 5ef969f56..0c4d11331 100644 --- a/src/core/shared/profile_paths.zig +++ b/src/core/shared/profile_paths.zig @@ -12,6 +12,7 @@ pub const prompt_history_file_name = "history.jsonl"; pub const usage_file_name = "usage.jsonl"; pub const usage_recovery_dir_name = "usage-recovery"; pub const backups_dir_name = "backups"; +pub const cache_dir_name = "cache"; pub const mcp_credentials_dir_name = "mcp-credentials"; pub const mcp_credentials_file_name = "credentials.json"; @@ -84,6 +85,10 @@ pub fn backupsDir(alloc: Allocator, home: []const u8) ![]u8 { return std.fs.path.join(alloc, &.{ home, root_dir_name, backups_dir_name }); } +pub fn cacheDir(alloc: Allocator, home: []const u8) ![]u8 { + return std.fs.path.join(alloc, &.{ home, root_dir_name, cache_dir_name }); +} + pub fn logsDir(alloc: Allocator, home: []const u8) ![]u8 { return std.fs.path.join(alloc, &.{ home, root_dir_name, logs_dir_name }); } @@ -157,6 +162,10 @@ test "profile path helpers preserve current default locations" { defer alloc.free(backups); try std.testing.expectEqualStrings("/tmp/fake-home/.fx/backups", backups); + const cache = try cacheDir(alloc, "/tmp/fake-home"); + defer alloc.free(cache); + try std.testing.expectEqualStrings("/tmp/fake-home/.fx/cache", cache); + const logs = try logsDir(alloc, "/tmp/fake-home"); defer alloc.free(logs); try std.testing.expectEqualStrings("/tmp/fake-home/.fx/logs", logs); diff --git a/src/gateway/openai_codex_models.zig b/src/gateway/openai_codex_models.zig index b6992e190..faf9e93f2 100644 --- a/src/gateway/openai_codex_models.zig +++ b/src/gateway/openai_codex_models.zig @@ -1,8 +1,11 @@ const std = @import("std"); const chatgpt_oauth = @import("../core/auth/chatgpt_oauth.zig"); +const catalog_disk_cache = @import("../core/gateway/catalog_disk_cache.zig"); const model_catalog = @import("../core/gateway/model_catalog.zig"); const gateway_provider = @import("../core/gateway/gateway_provider.zig"); +const debug_trace = @import("../core/shared/debug_trace.zig"); const io_mod = @import("../core/shared/io.zig"); +const profile_paths = @import("../core/shared/profile_paths.zig"); const secret = @import("../core/auth/secret.zig"); const types = @import("../core/shared/types.zig"); const gateway_client = @import("client.zig"); @@ -11,6 +14,7 @@ const max_catalog_models: usize = 128; const max_model_id_bytes: usize = 1024; const max_catalog_bytes: usize = 4 * 1024 * 1024; const fetch_timeout_ms: i64 = 30_000; +const catalog_cache_prefix = "codex-models"; const default_models_endpoint = "https://chatgpt.com/backend-api/codex/models"; const e2e_models_endpoint_env = "FX_E2E_OPENAI_CODEX_MODELS_URL"; @@ -68,6 +72,28 @@ fn fetchCatalogForProvider( return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; }; defer alloc.free(account_id); + + const cache_path = catalogCachePath(alloc, account_id) catch null; + defer if (cache_path) |path| alloc.free(path); + if (cache_path) |path| { + if (loadFreshCatalogCache(alloc, path, io_mod.milliTimestamp())) |body| { + defer secret.zeroAndFree(alloc, body); + if (parseValidatedCatalog(alloc, body)) |catalog| { + debug_trace.logf("catalog", "Codex model catalog cache outcome=hit", .{}); + return .{ .catalog = catalog }; + } else |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + debug_trace.logf( + "catalog", + "Codex model catalog cache outcome=invalid error={s}", + .{@errorName(err)}, + ); + } + } else { + debug_trace.logf("catalog", "Codex model catalog cache outcome=miss", .{}); + } + } + const request_url = modelsUrl(alloc) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return .{ .failure = .{ .category = .runtime } }; @@ -102,21 +128,25 @@ fn fetchCatalogForProvider( if (response.status != .ok) { return .{ .failure = model_catalog.failureForHttpStatus(response.status) }; } - var catalog = parseCatalog(alloc, response.body) catch |err| { + var catalog = parseValidatedCatalog(alloc, response.body) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return .{ .failure = .{ .category = .malformed_response, .http_status = .ok } }; }; - var reviewer_available = false; - for (catalog.items) |entry| { - if (std.mem.eql(u8, entry.id, reviewer_model)) { - reviewer_available = true; - break; + if (cache_path) |path| { + if (storeCatalogCache(alloc, path, response.body, io_mod.milliTimestamp())) { + debug_trace.logf("catalog", "Codex model catalog cache outcome=stored", .{}); + } else |err| { + if (err == error.OutOfMemory) { + model_catalog.freeModelCatalog(alloc, &catalog); + return error.OutOfMemory; + } + debug_trace.logf( + "catalog", + "Codex model catalog cache outcome=store_failed error={s}", + .{@errorName(err)}, + ); } } - if (!reviewer_available) { - model_catalog.freeModelCatalog(alloc, &catalog); - return .{ .failure = .{ .category = .malformed_response, .http_status = .ok } }; - } return .{ .catalog = catalog }; } @@ -185,6 +215,55 @@ fn modelsUrl(alloc: std.mem.Allocator) ![]u8 { ); } +const catalog_cache_config = catalog_disk_cache.Config{ + .file_prefix = catalog_cache_prefix, + .version = protocol_client_version, + .max_body_bytes = max_catalog_bytes, +}; + +/// The cache is bypassed entirely when the loopback e2e endpoint override is +/// active: e2e runs must observe every catalog request, and must not perform +/// durable writes outside the fixture sandbox. +fn catalogCacheEnabled() bool { + return io_mod.getenv(e2e_models_endpoint_env) == null; +} + +fn catalogCachePath(alloc: std.mem.Allocator, account_id: []const u8) ![]u8 { + if (!catalogCacheEnabled()) return error.CodexCatalogCacheDisabled; + // The account id partitions the cache so switching accounts can never + // serve another tenant's catalog. + return catalog_disk_cache.cachePath(alloc, catalog_cache_config, account_id); +} + +fn loadFreshCatalogCache(alloc: std.mem.Allocator, path: []const u8, now_ms: i64) ?[]u8 { + return catalog_disk_cache.loadFresh(alloc, catalog_cache_config, path, now_ms); +} + +fn storeCatalogCache( + alloc: std.mem.Allocator, + path: []const u8, + body: []const u8, + fetched_at_ms: i64, +) !void { + if (!catalogCacheEnabled()) return error.CodexCatalogCacheDisabled; + return catalog_disk_cache.store(alloc, catalog_cache_config, path, body, fetched_at_ms); +} + +/// Parses a catalog body and applies the same acceptance rule as the network +/// path: a catalog that does not include the reviewer model is rejected, so a +/// cached body can never be weaker than a fetched one. +fn parseValidatedCatalog( + alloc: std.mem.Allocator, + json_text: []const u8, +) !std.ArrayList(model_catalog.ModelCatalogEntry) { + var catalog = try parseCatalog(alloc, json_text); + errdefer model_catalog.freeModelCatalog(alloc, &catalog); + for (catalog.items) |entry| { + if (std.mem.eql(u8, entry.id, reviewer_model)) return catalog; + } + return error.InvalidCodexModelCatalog; +} + fn parseCatalog( alloc: std.mem.Allocator, json_text: []const u8, @@ -318,6 +397,52 @@ test "Codex catalog parser keeps visible API models and live capabilities" { try std.testing.expectEqual(@as(u32, 272_000), model.context_window); } +const test_catalog_body = + \\{"models":[ + \\ {"slug":"gpt-5.4-mini","visibility":"list","supported_in_api":true,"priority":7,"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],"additional_speed_tiers":[],"input_modalities":["text","image"],"context_window":272000} + \\]} +; + +fn testCachePath(alloc: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 { + const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(root); + return std.fs.path.join(alloc, &.{ root, "cache", "codex-models-test.json" }); +} + +test "Codex catalog cache round-trips a fresh body" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testCachePath(alloc, &tmp); + defer alloc.free(path); + + const stored_at_ms: i64 = 1_000_000; + try storeCatalogCache(alloc, path, test_catalog_body, stored_at_ms); + + const loaded = loadFreshCatalogCache(alloc, path, stored_at_ms + 1) orelse + return error.TestExpectedCacheHit; + defer alloc.free(loaded); + try std.testing.expectEqualStrings(test_catalog_body, loaded); + + var catalog = try parseValidatedCatalog(alloc, loaded); + defer model_catalog.freeModelCatalog(alloc, &catalog); + try std.testing.expectEqual(@as(usize, 1), catalog.items.len); + try std.testing.expectEqualStrings(reviewer_model, catalog.items[0].id); +} + +test "Codex validated catalog parse rejects a catalog without the reviewer model" { + const alloc = std.testing.allocator; + const missing_reviewer = + \\{"models":[ + \\ {"slug":"other-model","visibility":"list","supported_in_api":true,"priority":7,"supported_reasoning_levels":[],"additional_speed_tiers":[],"input_modalities":["text"],"context_window":1000} + \\]} + ; + try std.testing.expectError( + error.InvalidCodexModelCatalog, + parseValidatedCatalog(alloc, missing_reviewer), + ); +} + test "Codex catalog URL uses the live-validated protocol compatibility version" { const url = try modelsUrl(std.testing.allocator); defer std.testing.allocator.free(url); diff --git a/src/gateway/xai_grok_models.zig b/src/gateway/xai_grok_models.zig index 3394a6f70..ff3797a39 100644 --- a/src/gateway/xai_grok_models.zig +++ b/src/gateway/xai_grok_models.zig @@ -1,5 +1,7 @@ const std = @import("std"); const credentials = @import("../core/auth/credentials.zig"); +const catalog_disk_cache = @import("../core/gateway/catalog_disk_cache.zig"); +const debug_trace = @import("../core/shared/debug_trace.zig"); const grok_session = @import("../core/auth/grok_session.zig"); const model_catalog = @import("../core/gateway/model_catalog.zig"); const gateway_provider = @import("../core/gateway/gateway_provider.zig"); @@ -17,6 +19,56 @@ const default_modalities_endpoint = "https://api.x.ai/v1/language-models"; const e2e_models_endpoint_env = "FX_E2E_XAI_GROK_MODELS_URL"; const e2e_modalities_endpoint_env = "FX_E2E_XAI_GROK_MODALITIES_URL"; +const catalog_cache_config = catalog_disk_cache.Config{ + .file_prefix = "grok-models", + // The parse and merge rules live in this binary, so gate cache entries on + // the fx build that wrote them. + .version = gateway_client.user_agent, + // The envelope stores both response bodies JSON-escaped; leave room for + // worst-case escaping overhead. + .max_body_bytes = 4 * max_catalog_bytes + 4096, +}; + +/// The cache is bypassed entirely when either loopback e2e endpoint override +/// is active: e2e runs must observe every catalog request, and must not +/// perform durable writes outside the fixture sandbox. +fn catalogCacheEnabled() bool { + return io_mod.getenv(e2e_models_endpoint_env) == null and + io_mod.getenv(e2e_modalities_endpoint_env) == null; +} + +/// Both catalog endpoints are cached as one unit so a hit always reproduces +/// the same subscription/modalities join the network path performs. +const CachedCatalogBodies = struct { + subscription: []const u8, + modalities: []const u8, +}; + +fn encodeCachedCatalogBodies( + alloc: std.mem.Allocator, + subscription: []const u8, + modalities: []const u8, +) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + try std.json.Stringify.value(CachedCatalogBodies{ + .subscription = subscription, + .modalities = modalities, + }, .{}, &out.writer); + return alloc.dupe(u8, out.written()); +} + +fn parseCachedCatalog( + alloc: std.mem.Allocator, + combined: []const u8, +) !std.ArrayList(model_catalog.ModelCatalogEntry) { + var parsed = try std.json.parseFromSlice(CachedCatalogBodies, alloc, combined, .{ + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + return parseCatalog(alloc, parsed.value.subscription, parsed.value.modalities); +} + pub const model_catalog_provider = model_catalog.Provider{ .fetch_fn = fetchCatalogForProvider, }; @@ -68,6 +120,30 @@ fn fetchCatalogForProvider( if (!grok_session.validAccountId(account_id)) { return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; } + const cache_path: ?[]u8 = if (catalogCacheEnabled()) + catalog_disk_cache.cachePath(alloc, catalog_cache_config, account_id) catch null + else + null; + defer if (cache_path) |path| alloc.free(path); + if (cache_path) |path| { + if (catalog_disk_cache.loadFresh(alloc, catalog_cache_config, path, io_mod.milliTimestamp())) |combined| { + defer secret.zeroAndFree(alloc, combined); + if (parseCachedCatalog(alloc, combined)) |catalog| { + debug_trace.logf("catalog", "Grok model catalog cache outcome=hit", .{}); + return .{ .catalog = catalog }; + } else |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + debug_trace.logf( + "catalog", + "Grok model catalog cache outcome=invalid error={s}", + .{@errorName(err)}, + ); + } + } else { + debug_trace.logf("catalog", "Grok model catalog cache outcome=miss", .{}); + } + } + const request_url = modelsUrl(alloc) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return .{ .failure = .{ .category = .runtime } }; @@ -115,13 +191,38 @@ fn fetchCatalogForProvider( if (modalities_response.status != .ok) { return .{ .failure = model_catalog.failureForHttpStatus(modalities_response.status) }; } - const catalog = parseCatalog(alloc, response.body, modalities_response.body) catch |err| { + var catalog = parseCatalog(alloc, response.body, modalities_response.body) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return .{ .failure = .{ .category = .malformed_response, .http_status = .ok } }; }; + if (cache_path) |path| { + storeCatalogCache(alloc, path, response.body, modalities_response.body) catch |err| { + if (err == error.OutOfMemory) { + model_catalog.freeModelCatalog(alloc, &catalog); + return error.OutOfMemory; + } + debug_trace.logf( + "catalog", + "Grok model catalog cache outcome=store_failed error={s}", + .{@errorName(err)}, + ); + }; + } return .{ .catalog = catalog }; } +fn storeCatalogCache( + alloc: std.mem.Allocator, + path: []const u8, + subscription: []const u8, + modalities: []const u8, +) !void { + const combined = try encodeCachedCatalogBodies(alloc, subscription, modalities); + defer secret.zeroAndFree(alloc, combined); + try catalog_disk_cache.store(alloc, catalog_cache_config, path, combined, io_mod.milliTimestamp()); + debug_trace.logf("catalog", "Grok model catalog cache outcome=stored", .{}); +} + fn catalogFetchFailure(err: anyerror) model_catalog.Failure { if (err == error.Cancelled) return .{ .category = .cancellation }; if (err == error.GrokModelCatalogTooLarge) return .{ .category = .malformed_response }; @@ -419,6 +520,35 @@ test "Grok catalog parser joins provider-owned subscription capabilities and mod try std.testing.expect(!second.has_vision); } +test "Grok catalog cache round-trips combined subscription and modality bodies" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(root); + const path = try std.fs.path.join(alloc, &.{ root, "cache", "grok-models-test.json" }); + defer alloc.free(path); + + const subscription_json = + \\{"data":[{"id":"current-a","model":"current-a","api_backend":"responses","context_window":500123,"supports_reasoning_effort":false,"reasoning_efforts":[]}]} + ; + const modalities_json = + \\{"models":[{"id":"current-a","input_modalities":["text","image"],"output_modalities":["text"]}]} + ; + const combined = try encodeCachedCatalogBodies(alloc, subscription_json, modalities_json); + defer secret.zeroAndFree(alloc, combined); + try catalog_disk_cache.store(alloc, catalog_cache_config, path, combined, 1_000_000); + + const loaded = catalog_disk_cache.loadFresh(alloc, catalog_cache_config, path, 1_000_001) orelse + return error.TestExpectedCacheHit; + defer alloc.free(loaded); + var catalog = try parseCachedCatalog(alloc, loaded); + defer model_catalog.freeModelCatalog(alloc, &catalog); + try std.testing.expectEqual(@as(usize, 1), catalog.items.len); + try std.testing.expectEqualStrings("current-a", catalog.items[0].id); + try std.testing.expect(catalog.items[0].has_vision); +} + test "Grok catalog rejects missing provider-owned capability metadata" { const modalities = \\{"models":[{"id":"current","input_modalities":["text"],"output_modalities":["text"]}]} diff --git a/src/main.zig b/src/main.zig index 47163a8d2..c4b92e69e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4065,6 +4065,7 @@ test { _ = @import("core/auth/oauth_session.zig"); _ = @import("core/workspace/file_index.zig"); _ = @import("gateway/vercel_protocol.zig"); + _ = @import("core/gateway/catalog_disk_cache.zig"); _ = @import("core/gateway/provider_set.zig"); _ = @import("core/github/git_context.zig"); _ = @import("core/github/github_publish.zig");