diff --git a/fern/versions/latest/pages/model-server/adapters-caching.mdx b/fern/versions/latest/pages/model-server/adapters-caching.mdx new file mode 100644 index 0000000000..821f8edbf5 --- /dev/null +++ b/fern/versions/latest/pages/model-server/adapters-caching.mdx @@ -0,0 +1,26 @@ +--- +title: "Caching Interceptor" +description: "Disk-backed cache that short-circuits the chain on cache hit." +position: 5 +--- + +Disk-backed cache keyed by a SHA-256 hash of the canonicalized request body (+ optional session prefix). On cache hit, the interceptor short-circuits the chain and returns the stored response without invoking the upstream. + +| Name | Stage | Purpose | +|------|-------|---------| +| `caching` | request → response | Disk-backed cache (sqlite). Hits short-circuit upstream. | + +### `caching` + +```yaml +- name: caching + config: + cache_dir: /var/cache/gym-adapter + bypass: false +``` + +Cache keys include the session prefix when present (via `ctx.extra["session_id"]`), so the same body in different sessions does not collide. + + +Per-replica proxy or server instance means per-replica disk cache: if multiple replicas share the same `cache_dir`, sqlite write contention is real. Configure a per-replica path (e.g. `cache_dir: /tmp/adapter_cache_${pod_id}`) or accept the race. + diff --git a/nemo_gym/adapters/cache/__init__.py b/nemo_gym/adapters/cache/__init__.py new file mode 100644 index 0000000000..467079831e --- /dev/null +++ b/nemo_gym/adapters/cache/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/nemo_gym/adapters/cache/disk_cache.py b/nemo_gym/adapters/cache/disk_cache.py new file mode 100644 index 0000000000..f425407465 --- /dev/null +++ b/nemo_gym/adapters/cache/disk_cache.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +import sqlite3 +import threading +import time +from typing import Any + + +logger = logging.getLogger(__name__) + +_RELEVANT_KEYS = ("model", "messages", "tools", "temperature", "max_tokens", "top_p", "seed") + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS cache_v1 ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at REAL NOT NULL +); +""" + +_UPSERT = """ +INSERT INTO cache_v1 (key, value, created_at) VALUES (?, ?, ?) +ON CONFLICT(key) DO UPDATE SET value=excluded.value, created_at=excluded.created_at; +""" + + +class DiskCache: + def __init__(self, cache_dir: str) -> None: + os.makedirs(cache_dir, exist_ok=True) + self._db_path = os.path.join(cache_dir, "adapter_cache.db") + self._lock = threading.Lock() + self._init_db() + + def _init_db(self) -> None: + try: + with self._lock: + conn = sqlite3.connect(self._db_path) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + conn.execute(_SCHEMA) + conn.commit() + finally: + conn.close() + except sqlite3.Error: + logger.warning("Failed to initialize cache DB at %s", self._db_path, exc_info=True) + + @staticmethod + def cache_key(body: dict[str, Any], *, session_prefix: str = "") -> str: + canonical: dict[str, Any] = {} + for k in _RELEVANT_KEYS: + if k in body: + canonical[k] = body[k] + if "extra_body" in body and isinstance(body["extra_body"], dict): + canonical["extra_body"] = body["extra_body"] + raw = json.dumps(canonical, sort_keys=True, ensure_ascii=False) + if session_prefix: + raw = session_prefix + "|" + raw + return hashlib.sha256(raw.encode()).hexdigest() + + def _get_sync(self, key: str) -> dict | None: + try: + with self._lock: + conn = sqlite3.connect(self._db_path) + try: + row = conn.execute("SELECT value FROM cache_v1 WHERE key = ?", (key,)).fetchone() + finally: + conn.close() + if row is None: + return None + return json.loads(row[0]) + except (sqlite3.Error, json.JSONDecodeError): + logger.warning("Cache get failed for key=%s", key[:16], exc_info=True) + return None + + def _set_sync(self, key: str, value: dict) -> None: + try: + serialized = json.dumps(value, ensure_ascii=False) + with self._lock: + conn = sqlite3.connect(self._db_path) + try: + conn.execute(_UPSERT, (key, serialized, time.time())) + conn.commit() + finally: + conn.close() + except sqlite3.Error: + logger.warning("Cache set failed for key=%s", key[:16], exc_info=True) + + async def get(self, key: str) -> dict | None: + return await asyncio.to_thread(self._get_sync, key) + + async def set(self, key: str, value: dict) -> None: + await asyncio.to_thread(self._set_sync, key, value) diff --git a/nemo_gym/adapters/interceptors/caching.py b/nemo_gym/adapters/interceptors/caching.py new file mode 100644 index 0000000000..f5ffd302d3 --- /dev/null +++ b/nemo_gym/adapters/interceptors/caching.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import logging + +from nemo_gym.adapters.cache.disk_cache import DiskCache +from nemo_gym.adapters.types import ( + AdapterRequest, + AdapterResponse, + RequestToResponseInterceptor, + ResponseInterceptor, +) + + +logger = logging.getLogger(__name__) + + +class Interceptor(RequestToResponseInterceptor, ResponseInterceptor): + stream_safe = False + + def __init__(self, cache_dir: str, *, bypass: bool = False) -> None: + self._bypass = bypass + self._cache = DiskCache(cache_dir) + + async def intercept_request( + self, + req: AdapterRequest, + ) -> AdapterRequest | AdapterResponse: + if self._bypass: + return req + session_prefix = req.ctx.extra.get("session_id", "") + key = DiskCache.cache_key(req.body, session_prefix=session_prefix) + hit = await self._cache.get(key) + if hit is not None: + logger.debug("cache hit key=%s", key[:16]) + req.ctx.extra["cache_hit"] = True + return AdapterResponse( + status_code=200, + headers={}, + body=hit, + latency_ms=0.0, + ctx=req.ctx, + ) + req.ctx.extra["cache_key"] = key + return req + + async def intercept_response(self, resp: AdapterResponse) -> AdapterResponse: + key = resp.ctx.extra.get("cache_key") + if key is None or not resp.ok: + return resp + if isinstance(resp.body, dict): + await self._cache.set(key, resp.body) + resp.ctx.extra.pop("cache_key", None) + return resp diff --git a/nemo_gym/adapters/registry.py b/nemo_gym/adapters/registry.py index 1fe1a8a77f..868008a34d 100644 --- a/nemo_gym/adapters/registry.py +++ b/nemo_gym/adapters/registry.py @@ -52,6 +52,9 @@ # Each family adds entries under its own family-named comment marker so # different families don't fight for the same diff context. +# Caching family. +_BUILTIN["caching"] = "nemo_gym.adapters.interceptors.caching" + # External / plugin registrations at runtime. _EXTRA: dict[str, str] = {} diff --git a/tests/unit_tests/test_adapter_cache_keys.py b/tests/unit_tests/test_adapter_cache_keys.py new file mode 100644 index 0000000000..1fbeab0e89 --- /dev/null +++ b/tests/unit_tests/test_adapter_cache_keys.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Cache-key derivation golden tests — ported from NEL. + +The golden SHA-256 strings are byte-identical to NEL's: the cache_key +algorithm is the same code re-rooted at ``nemo_gym.adapters.cache``, so +both implementations must produce the same digests for the same inputs. +This file protects against accidental drift. +""" + +from nemo_gym.adapters.cache.disk_cache import DiskCache + + +def _compute_key(body): + return DiskCache.cache_key(body) + + +def test_golden_key_simple(): + body = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "temperature": 0.7} + key = _compute_key(body) + assert key == _compute_key(body) + assert key == "92232807c9b5c0bab68fd1abb38bc83c884fb04b0eb9b8b453c19daead7d682f" # pragma: allowlist secret + + +def test_golden_key_with_tools(): + body = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "hello"}], + "tools": [{"type": "function", "function": {"name": "get_weather"}}], + } + key = _compute_key(body) + assert key == _compute_key(body) + assert key == "ce43d524c627c96eaadbb9abdac38ad5eb60a93d15b1d26880fa1fe0f86d4c68" # pragma: allowlist secret + + +def test_key_ignores_irrelevant_fields(): + base = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]} + with_stream = {**base, "stream": True} + assert _compute_key(base) == _compute_key(with_stream) + + +def test_key_changes_with_temperature(): + body_a = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "temperature": 0.7} + body_b = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "temperature": 0.9} + assert _compute_key(body_a) != _compute_key(body_b) + + +def test_key_differs_with_session_prefix(): + """Session prefix ensures repeats of the same problem never share cache entries.""" + body = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]} + k_none = DiskCache.cache_key(body) + k_a = DiskCache.cache_key(body, session_prefix="repeat-0") + k_b = DiskCache.cache_key(body, session_prefix="repeat-1") + assert k_none != k_a + assert k_a != k_b + assert k_none == DiskCache.cache_key(body, session_prefix="") diff --git a/tests/unit_tests/test_adapter_disk_cache.py b/tests/unit_tests/test_adapter_disk_cache.py new file mode 100644 index 0000000000..db712a7777 --- /dev/null +++ b/tests/unit_tests/test_adapter_disk_cache.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""DiskCache behavior tests — ported from NEL.""" + +import asyncio + +import pytest + +from nemo_gym.adapters.cache.disk_cache import DiskCache + + +@pytest.fixture +def cache(tmp_path): + return DiskCache(str(tmp_path / "cache")) + + +def test_cache_key_deterministic(): + body = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]} + assert DiskCache.cache_key(body) == DiskCache.cache_key(body) + + +def test_cache_key_varies_with_model(): + base = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]} + other = {**base, "model": "gpt-3.5-turbo"} + assert DiskCache.cache_key(base) != DiskCache.cache_key(other) + + +async def test_get_set_roundtrip(cache): + key = DiskCache.cache_key({"model": "x", "messages": []}) + value = {"choices": [{"message": {"content": "hi"}}]} + await cache.set(key, value) + result = await cache.get(key) + assert result == value + + +async def test_get_missing_returns_none(cache): + result = await cache.get("nonexistent_key") + assert result is None + + +async def test_concurrent_writes(cache): + pairs = [(f"key_{i}", {"i": i}) for i in range(50)] + + await asyncio.gather(*(cache.set(k, v) for k, v in pairs)) + + for k, v in pairs: + assert await cache.get(k) == v + + +async def test_degrade_on_bad_path(): + bad = DiskCache.__new__(DiskCache) + bad._db_path = "/dev/null/bad/adapter_cache.db" + bad._lock = __import__("threading").Lock() + + assert await bad.get("anything") is None + await bad.set("anything", {"x": 1}) diff --git a/tests/unit_tests/test_adapter_interceptors_caching.py b/tests/unit_tests/test_adapter_interceptors_caching.py new file mode 100644 index 0000000000..3eb0fd7cd1 --- /dev/null +++ b/tests/unit_tests/test_adapter_interceptors_caching.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Per-interceptor behavior tests — ported from NEL ``tests/test_adapters/test_interceptors.py`` +and ``test_interceptors_extended.py``. + +Mechanical port from ``nemo_evaluator.adapters.*`` to ``nemo_gym.adapters.*``; +``GracefulError`` re-rooted at ``nemo_gym.adapters.types``. +""" + +from nemo_gym.adapters.types import ( + AdapterRequest, + AdapterResponse, + InterceptorContext, +) + + +def _req(body=None, **kw): + return AdapterRequest( + method="POST", + path="/v1/chat/completions", + headers={"content-type": "application/json"}, + body=body or {"model": "test", "messages": [{"role": "user", "content": "hi"}]}, + ctx=InterceptorContext(), + ) + + +def _resp(body=None, status_code=200): + return AdapterResponse( + status_code=status_code, + headers={}, + body=body or {}, + ctx=InterceptorContext(), + ) + + +class TestCachingInterceptor: + async def test_cache_miss_passes_through(self, tmp_path): + from nemo_gym.adapters.interceptors.caching import Interceptor + + i = Interceptor(cache_dir=str(tmp_path)) + ctx = InterceptorContext() + req = AdapterRequest( + method="POST", + path="/chat/completions", + headers={}, + body={"messages": [{"role": "user", "content": "hi"}]}, + ctx=ctx, + ) + result = await i.intercept_request(req) + assert isinstance(result, AdapterRequest) + + async def test_bypass_mode(self, tmp_path): + from nemo_gym.adapters.interceptors.caching import Interceptor + + i = Interceptor(cache_dir=str(tmp_path), bypass=True) + ctx = InterceptorContext() + req = AdapterRequest( + method="POST", + path="/v1/chat/completions", + headers={}, + body={"messages": []}, + ctx=ctx, + ) + result = await i.intercept_request(req) + assert isinstance(result, AdapterRequest) + + async def test_cache_hit_returns_response(self, tmp_path): + from nemo_gym.adapters.interceptors.caching import Interceptor + + i = Interceptor(cache_dir=str(tmp_path)) + ctx = InterceptorContext() + body = {"messages": [{"role": "user", "content": "cached"}]} + req = AdapterRequest(method="POST", path="/chat/completions", headers={}, body=body, ctx=ctx) + + result1 = await i.intercept_request(req) + assert isinstance(result1, AdapterRequest) + + resp = AdapterResponse( + status_code=200, + headers={}, + body={"choices": [{"message": {"content": "answer"}}]}, + ctx=ctx, + ) + await i.intercept_response(resp) + + req2 = AdapterRequest(method="POST", path="/chat/completions", headers={}, body=body, ctx=InterceptorContext()) + result2 = await i.intercept_request(req2) + assert isinstance(result2, AdapterResponse) + assert result2.status_code == 200 + + async def test_cache_isolated_by_session(self, tmp_path): + """Repeats with different session IDs never share cache entries.""" + from nemo_gym.adapters.interceptors.caching import Interceptor + + i = Interceptor(cache_dir=str(tmp_path)) + body = {"messages": [{"role": "user", "content": "hello"}]} + + ctx_a = InterceptorContext() + ctx_a.extra["session_id"] = "session_aaa" + req_a = AdapterRequest(method="POST", path="/chat/completions", headers={}, body=body, ctx=ctx_a) + result_a = await i.intercept_request(req_a) + assert isinstance(result_a, AdapterRequest) + + resp_a = AdapterResponse( + status_code=200, headers={}, body={"choices": [{"message": {"content": "answer-a"}}]}, ctx=ctx_a + ) + await i.intercept_response(resp_a) + + ctx_b = InterceptorContext() + ctx_b.extra["session_id"] = "session_bbb" + req_b = AdapterRequest(method="POST", path="/chat/completions", headers={}, body=body, ctx=ctx_b) + result_b = await i.intercept_request(req_b) + assert isinstance(result_b, AdapterRequest), "must be a cache miss, not a hit" + + +# =========================================================================== +# Observation-interceptor behavior tests — closes the gap the architect +# flagged: progress_tracking / response_stats / request_logging previously +# had only smoke + parity-replay coverage; this section asserts each +# observation interceptor does its actual job (right counter, right log +# field, right webhook payload). Each interceptor gets 2-3 cases. +# =========================================================================== diff --git a/tests/unit_tests/test_adapter_parity_replay_caching.py b/tests/unit_tests/test_adapter_parity_replay_caching.py new file mode 100644 index 0000000000..1c73e257b9 --- /dev/null +++ b/tests/unit_tests/test_adapter_parity_replay_caching.py @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Parity replay test for the adapter middleware. + +Loads every JSON fixture under ``adapter_fixtures/`` and asserts that the +recorded ``request`` → ``expected_response`` pair still holds when the +middleware is installed with the recorded ``interceptor_specs`` and the +(mocked) upstream returns the recorded ``upstream_response``. ``caching`` +is covered by a dedicated round-trip test below since its parity behavior +is "second hit ≡ first response". +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from nemo_gym.adapters import install_middleware + + +FIXTURE_DIR = Path(__file__).parent / "adapter_fixtures" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _load_fixtures() -> list[tuple[str, dict[str, Any]]]: + """Return ``[(fixture_name, fixture_data), ...]`` sorted by name.""" + if not FIXTURE_DIR.exists(): + return [] + fixtures: list[tuple[str, dict[str, Any]]] = [] + for path in sorted(FIXTURE_DIR.glob("*.json")): + with path.open() as f: + fixtures.append((path.stem, json.load(f))) + return fixtures + + +_FIXTURES = _load_fixtures() + + +def _build_replay_app(interceptor_specs: list[dict[str, Any]], upstream: dict[str, Any]) -> FastAPI: + """FastAPI app whose chat-completions route returns ``upstream`` verbatim, + with the adapter middleware installed on top.""" + app = FastAPI() + + @app.post("/v1/chat/completions") + async def _chat(body: dict): + return JSONResponse( + content=upstream["body"], + status_code=upstream["status_code"], + headers=upstream.get("headers") or {}, + ) + + install_middleware(app, interceptor_specs) + return app + + +_VOLATILE_HEADERS = {"date", "server", "content-length"} + + +def _normalise_headers(headers: dict[str, str]) -> dict[str, str]: + """Drop headers whose values are non-deterministic. + + Matches the normalisation in ``generate_adapter_fixtures.py``. Without this, + replays would fail because ``content-length`` is recomputed per-response + by Starlette and ``date`` / ``server`` vary across runs. + """ + return {k: v for k, v in headers.items() if k.lower() not in _VOLATILE_HEADERS} + + +# --------------------------------------------------------------------------- +# caching: dedicated round-trip parity check +# --------------------------------------------------------------------------- + + +def test_caching_round_trip_returns_same_response(tmp_path) -> None: + """First call populates the disk cache; the second call must return the + same response body, byte-equal, via the cache-hit path. + + This exercises the ``caching`` interceptor end-to-end through the + middleware (RequestToResponseInterceptor short-circuit on hit, plus + the matching ResponseInterceptor write on miss). NEL's tests verified + this at the interceptor unit level; here we pin the middleware-level + contract that a second identical request returns an identical body. + """ + interceptor_specs = [{"name": "caching", "config": {"cache_dir": str(tmp_path / "cache")}}] + upstream = { + "status_code": 200, + "headers": {"content-type": "application/json"}, + "body": { + "id": "chatcmpl-cache-test", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "cached-value"}, + } + ], + }, + } + app = _build_replay_app(interceptor_specs, upstream) + request_body = {"model": "m", "messages": [{"role": "user", "content": "same-prompt"}]} + + with TestClient(app) as client: + first = client.post("/v1/chat/completions", json=request_body) + second = client.post("/v1/chat/completions", json=request_body) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json() == upstream["body"] + # Second call must return byte-equal body. The headers can differ (the + # cache-hit path constructs its own response without copying upstream + # headers), so we don't compare headers here. + assert second.json() == first.json()