From 79ae5c8961be6d8e1f1a65e7710981a643d7f445 Mon Sep 17 00:00:00 2001 From: mjlbach Date: Sat, 29 Aug 2026 09:42:45 -0700 Subject: [PATCH 1/2] Cache the Codex model catalog on disk Store the authenticated Codex model catalog under ~/.fx/cache/codex-models-.json and serve it for six hours before refetching. Entries are gated on a cache schema version and the Codex protocol client version, partitioned per account, written atomically, and validated with the same rules as endpoint responses. Every cache failure is a miss, never an error. The cache is disabled under the FX_E2E_OPENAI_CODEX_MODELS_URL override so e2e runs observe every request and perform no durable writes. Measured live: cold catalog fetch 1,221 ms; warm cache hit ~8 ms. Co-Authored-By: Claude Fable 5 --- src/core/shared/profile_paths.zig | 9 + src/gateway/openai_codex_models.zig | 278 +++++++++++++++++++++++++++- 2 files changed, 277 insertions(+), 10 deletions(-) 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..ef621534e 100644 --- a/src/gateway/openai_codex_models.zig +++ b/src/gateway/openai_codex_models.zig @@ -2,7 +2,9 @@ const std = @import("std"); const chatgpt_oauth = @import("../core/auth/chatgpt_oauth.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 +13,8 @@ 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_ttl_ms: i64 = 6 * std.time.ms_per_hour; +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,105 @@ fn modelsUrl(alloc: std.mem.Allocator) ![]u8 { ); } +const catalog_cache_schema_version: u32 = 1; +const max_catalog_cache_bytes: usize = max_catalog_bytes + 4096; + +const CatalogCacheEnvelope = struct { + schema_version: u32, + protocol_client_version: []const u8, + fetched_at_ms: i64, + body: []const u8, +}; + +/// 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; + const home = io_mod.getenv("HOME") orelse return error.CodexCatalogCacheUnavailable; + if (home.len == 0 or !std.fs.path.isAbsolute(home)) return error.CodexCatalogCacheUnavailable; + const cache_dir = try profile_paths.cacheDir(alloc, home); + defer alloc.free(cache_dir); + // The account id partitions the cache so switching accounts can never + // serve another tenant's catalog; hashing keeps ids out of file names. + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(account_id, &digest, .{}); + const account_hash = std.fmt.bytesToHex(digest[0..8].*, .lower); + const file_name = try std.fmt.allocPrint( + alloc, + "{s}-{s}.json", + .{ catalog_cache_prefix, account_hash }, + ); + defer alloc.free(file_name); + return std.fs.path.join(alloc, &.{ cache_dir, file_name }); +} + +/// Best-effort read of the cached catalog body. Any failure — missing file, +/// oversized file, malformed envelope, schema or protocol-version mismatch, +/// stale or future timestamp — is a miss, never an error: the caller falls +/// back to the network fetch. +fn loadFreshCatalogCache(alloc: std.mem.Allocator, 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, max_catalog_cache_bytes) catch return null; + defer secret.zeroAndFree(alloc, data); + var parsed = std.json.parseFromSlice(CatalogCacheEnvelope, alloc, data, .{ + .ignore_unknown_fields = true, + }) catch return null; + defer parsed.deinit(); + const envelope = parsed.value; + if (envelope.schema_version != catalog_cache_schema_version) return null; + if (!std.mem.eql(u8, envelope.protocol_client_version, protocol_client_version)) return null; + if (envelope.fetched_at_ms > now_ms) return null; + if (now_ms - envelope.fetched_at_ms >= catalog_cache_ttl_ms) return null; + if (envelope.body.len == 0 or envelope.body.len > max_catalog_bytes) return null; + return alloc.dupe(u8, envelope.body) catch null; +} + +fn storeCatalogCache( + alloc: std.mem.Allocator, + path: []const u8, + body: []const u8, + fetched_at_ms: i64, +) !void { + if (!catalogCacheEnabled()) return error.CodexCatalogCacheDisabled; + if (body.len == 0 or body.len > max_catalog_bytes) return error.CodexModelCatalogTooLarge; + const parent = std.fs.path.dirname(path) orelse return error.CodexCatalogCacheUnavailable; + try io_mod.makeDirRecursive(parent); + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + try std.json.Stringify.value(CatalogCacheEnvelope{ + .schema_version = catalog_cache_schema_version, + .protocol_client_version = protocol_client_version, + .fetched_at_ms = fetched_at_ms, + .body = body, + }, .{}, &out.writer); + try io_mod.writeFileAtomic(alloc, path, out.written()); +} + +/// 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 +447,135 @@ 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 catalog 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 storeCatalogCache(alloc, path, test_catalog_body, stored_at_ms); + + // Exactly at the TTL boundary and beyond: stale. + try std.testing.expectEqual( + @as(?[]u8, null), + loadFreshCatalogCache(alloc, path, stored_at_ms + catalog_cache_ttl_ms), + ); + // A fetch timestamp in the future is rejected, not trusted. + try std.testing.expectEqual( + @as(?[]u8, null), + loadFreshCatalogCache(alloc, path, stored_at_ms - 1), + ); + + // A schema-version bump invalidates the entry. + const wrong_schema = try std.fmt.allocPrint( + alloc, + "{{\"schema_version\":{d},\"protocol_client_version\":\"{s}\",\"fetched_at_ms\":{d},\"body\":\"{{}}\"}}", + .{ catalog_cache_schema_version + 1, protocol_client_version, stored_at_ms }, + ); + defer alloc.free(wrong_schema); + try io_mod.writeFileAtomic(alloc, path, wrong_schema); + try std.testing.expectEqual( + @as(?[]u8, null), + loadFreshCatalogCache(alloc, path, stored_at_ms + 1), + ); + + // A different protocol client version invalidates the entry. + const wrong_version = try std.fmt.allocPrint( + alloc, + "{{\"schema_version\":{d},\"protocol_client_version\":\"0.0.1\",\"fetched_at_ms\":{d},\"body\":\"{{}}\"}}", + .{ catalog_cache_schema_version, stored_at_ms }, + ); + defer alloc.free(wrong_version); + try io_mod.writeFileAtomic(alloc, path, wrong_version); + try std.testing.expectEqual( + @as(?[]u8, null), + loadFreshCatalogCache(alloc, 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), + loadFreshCatalogCache(alloc, 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), + loadFreshCatalogCache(alloc, missing, stored_at_ms + 1), + ); +} + +test "Codex catalog 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.CodexModelCatalogTooLarge, + storeCatalogCache(alloc, path, "", 0), + ); + const oversized = try alloc.alloc(u8, max_catalog_bytes + 1); + defer alloc.free(oversized); + @memset(oversized, 'a'); + try std.testing.expectError( + error.CodexModelCatalogTooLarge, + storeCatalogCache(alloc, path, oversized, 0), + ); +} + +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); From 6df5322548a7bdc4cdea833da515b4902daed2c4 Mon Sep 17 00:00:00 2001 From: mjlbach Date: Sat, 29 Aug 2026 10:02:35 -0700 Subject: [PATCH 2/2] Extend the catalog disk cache to Grok and gateway catalogs Extract the envelope, load, and store mechanics into core/gateway/catalog_disk_cache.zig and key every entry on a provider config: file prefix, format version, TTL, and body bound. Codex keeps its protocol client version as the format gate; Grok and the gateway gate on the fx build version because their parse rules live in this binary. Grok caches both catalog endpoints as one unit so a hit reproduces the same subscription/modalities join the network path performs. The gateway caches the raw catalog body at fetchModelCatalogResponse, the choke point shared by the runtime provider and the picker, partitioned by credential source, team, account (or credential when no stable account id exists), and resolved URL; bodies are stored only after they parse with the full-view rules. Each provider disables its cache under its loopback e2e endpoint overrides. Co-Authored-By: Claude Fable 5 --- src/builtins/gateway.zig | 135 ++++++++++++++- src/core/gateway/catalog_disk_cache.zig | 219 ++++++++++++++++++++++++ src/gateway/openai_codex_models.zig | 151 +--------------- src/gateway/xai_grok_models.zig | 132 +++++++++++++- src/main.zig | 1 + 5 files changed, 492 insertions(+), 146 deletions(-) create mode 100644 src/core/gateway/catalog_disk_cache.zig 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/gateway/openai_codex_models.zig b/src/gateway/openai_codex_models.zig index ef621534e..faf9e93f2 100644 --- a/src/gateway/openai_codex_models.zig +++ b/src/gateway/openai_codex_models.zig @@ -1,5 +1,6 @@ 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"); @@ -13,7 +14,6 @@ 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_ttl_ms: i64 = 6 * std.time.ms_per_hour; 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"; @@ -215,14 +215,10 @@ fn modelsUrl(alloc: std.mem.Allocator) ![]u8 { ); } -const catalog_cache_schema_version: u32 = 1; -const max_catalog_cache_bytes: usize = max_catalog_bytes + 4096; - -const CatalogCacheEnvelope = struct { - schema_version: u32, - protocol_client_version: []const u8, - fetched_at_ms: i64, - body: []const 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 @@ -234,48 +230,13 @@ fn catalogCacheEnabled() bool { fn catalogCachePath(alloc: std.mem.Allocator, account_id: []const u8) ![]u8 { if (!catalogCacheEnabled()) return error.CodexCatalogCacheDisabled; - const home = io_mod.getenv("HOME") orelse return error.CodexCatalogCacheUnavailable; - if (home.len == 0 or !std.fs.path.isAbsolute(home)) return error.CodexCatalogCacheUnavailable; - const cache_dir = try profile_paths.cacheDir(alloc, home); - defer alloc.free(cache_dir); // The account id partitions the cache so switching accounts can never - // serve another tenant's catalog; hashing keeps ids out of file names. - var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(account_id, &digest, .{}); - const account_hash = std.fmt.bytesToHex(digest[0..8].*, .lower); - const file_name = try std.fmt.allocPrint( - alloc, - "{s}-{s}.json", - .{ catalog_cache_prefix, account_hash }, - ); - defer alloc.free(file_name); - return std.fs.path.join(alloc, &.{ cache_dir, file_name }); + // serve another tenant's catalog. + return catalog_disk_cache.cachePath(alloc, catalog_cache_config, account_id); } -/// Best-effort read of the cached catalog body. Any failure — missing file, -/// oversized file, malformed envelope, schema or protocol-version mismatch, -/// stale or future timestamp — is a miss, never an error: the caller falls -/// back to the network fetch. fn loadFreshCatalogCache(alloc: std.mem.Allocator, 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, max_catalog_cache_bytes) catch return null; - defer secret.zeroAndFree(alloc, data); - var parsed = std.json.parseFromSlice(CatalogCacheEnvelope, alloc, data, .{ - .ignore_unknown_fields = true, - }) catch return null; - defer parsed.deinit(); - const envelope = parsed.value; - if (envelope.schema_version != catalog_cache_schema_version) return null; - if (!std.mem.eql(u8, envelope.protocol_client_version, protocol_client_version)) return null; - if (envelope.fetched_at_ms > now_ms) return null; - if (now_ms - envelope.fetched_at_ms >= catalog_cache_ttl_ms) return null; - if (envelope.body.len == 0 or envelope.body.len > max_catalog_bytes) return null; - return alloc.dupe(u8, envelope.body) catch null; + return catalog_disk_cache.loadFresh(alloc, catalog_cache_config, path, now_ms); } fn storeCatalogCache( @@ -285,18 +246,7 @@ fn storeCatalogCache( fetched_at_ms: i64, ) !void { if (!catalogCacheEnabled()) return error.CodexCatalogCacheDisabled; - if (body.len == 0 or body.len > max_catalog_bytes) return error.CodexModelCatalogTooLarge; - const parent = std.fs.path.dirname(path) orelse return error.CodexCatalogCacheUnavailable; - try io_mod.makeDirRecursive(parent); - var out: std.Io.Writer.Allocating = .init(alloc); - defer out.deinit(); - try std.json.Stringify.value(CatalogCacheEnvelope{ - .schema_version = catalog_cache_schema_version, - .protocol_client_version = protocol_client_version, - .fetched_at_ms = fetched_at_ms, - .body = body, - }, .{}, &out.writer); - try io_mod.writeFileAtomic(alloc, path, out.written()); + 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 @@ -480,89 +430,6 @@ test "Codex catalog cache round-trips a fresh body" { try std.testing.expectEqualStrings(reviewer_model, catalog.items[0].id); } -test "Codex catalog 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 storeCatalogCache(alloc, path, test_catalog_body, stored_at_ms); - - // Exactly at the TTL boundary and beyond: stale. - try std.testing.expectEqual( - @as(?[]u8, null), - loadFreshCatalogCache(alloc, path, stored_at_ms + catalog_cache_ttl_ms), - ); - // A fetch timestamp in the future is rejected, not trusted. - try std.testing.expectEqual( - @as(?[]u8, null), - loadFreshCatalogCache(alloc, path, stored_at_ms - 1), - ); - - // A schema-version bump invalidates the entry. - const wrong_schema = try std.fmt.allocPrint( - alloc, - "{{\"schema_version\":{d},\"protocol_client_version\":\"{s}\",\"fetched_at_ms\":{d},\"body\":\"{{}}\"}}", - .{ catalog_cache_schema_version + 1, protocol_client_version, stored_at_ms }, - ); - defer alloc.free(wrong_schema); - try io_mod.writeFileAtomic(alloc, path, wrong_schema); - try std.testing.expectEqual( - @as(?[]u8, null), - loadFreshCatalogCache(alloc, path, stored_at_ms + 1), - ); - - // A different protocol client version invalidates the entry. - const wrong_version = try std.fmt.allocPrint( - alloc, - "{{\"schema_version\":{d},\"protocol_client_version\":\"0.0.1\",\"fetched_at_ms\":{d},\"body\":\"{{}}\"}}", - .{ catalog_cache_schema_version, stored_at_ms }, - ); - defer alloc.free(wrong_version); - try io_mod.writeFileAtomic(alloc, path, wrong_version); - try std.testing.expectEqual( - @as(?[]u8, null), - loadFreshCatalogCache(alloc, 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), - loadFreshCatalogCache(alloc, 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), - loadFreshCatalogCache(alloc, missing, stored_at_ms + 1), - ); -} - -test "Codex catalog 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.CodexModelCatalogTooLarge, - storeCatalogCache(alloc, path, "", 0), - ); - const oversized = try alloc.alloc(u8, max_catalog_bytes + 1); - defer alloc.free(oversized); - @memset(oversized, 'a'); - try std.testing.expectError( - error.CodexModelCatalogTooLarge, - storeCatalogCache(alloc, path, oversized, 0), - ); -} - test "Codex validated catalog parse rejects a catalog without the reviewer model" { const alloc = std.testing.allocator; const missing_reviewer = 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");