Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44a6a2703 | ||
|
|
f559ae1649 | ||
|
|
8ed9a4ebb2 | ||
|
|
722aa902fb | ||
|
|
5c7e783e18 | ||
|
|
e779f10fc7 | ||
|
|
1b2c9f63aa | ||
|
|
7ffe6a104b | ||
|
|
d0a6dcf770 | ||
|
|
113d7da987 | ||
|
|
bd86e7ead0 | ||
|
|
795c081d9f | ||
|
|
9c161e4e59 | ||
|
|
9643e66e77 | ||
|
|
c29c76e3fa | ||
|
|
c16d9978e8 | ||
|
|
943dfee624 | ||
|
|
ab0caa658e | ||
|
|
2cb65e09e3 | ||
|
|
d1903f3c9f | ||
|
|
9784f6573a | ||
|
|
f02e037bc7 | ||
|
|
87734b3a44 | ||
|
|
f613f005a6 | ||
|
|
6a5e2d1800 | ||
|
|
6d495290bc | ||
|
|
f94e840fe8 | ||
|
|
25052a56d0 | ||
|
|
8b10231b8b | ||
|
|
5a7996a649 | ||
|
|
059d1c3e94 | ||
|
|
b20c0d8f67 | ||
|
|
debbd91961 | ||
|
|
ed35894f55 | ||
|
|
190c31f543 | ||
|
|
5a2c138779 | ||
|
|
51ea9aa286 | ||
|
|
f9fbfe55c2 | ||
|
|
5c7aea4717 | ||
|
|
9dfbfb4bd0 |
@@ -3463,6 +3463,8 @@ jobs:
|
||||
|
||||
verify-generated-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_FROZEN: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
@@ -30,7 +30,7 @@ It eliminates the shortcomings of alternative techniques such as RAG and knowled
|
||||
|
||||
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
|
||||
|
||||

|
||||

|
||||
|
||||
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.6.1
|
||||
appVersion: "0.6.1"
|
||||
version: 0.6.2
|
||||
appVersion: "0.6.2"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-all",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all-slim"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"hindsight-api-slim==0.6.1",
|
||||
"hindsight-api-slim==0.6.2",
|
||||
"hindsight-client>=0.0.7",
|
||||
"hindsight-embed>=0.1.0",
|
||||
]
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"hindsight-api-slim[all]==0.6.1",
|
||||
"hindsight-api-slim[all]==0.6.2",
|
||||
"hindsight-client>=0.0.7",
|
||||
"hindsight-embed>=0.1.0",
|
||||
]
|
||||
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
local-llm = [
|
||||
"hindsight-api-slim[local-llm]==0.6.1",
|
||||
"hindsight-api-slim[local-llm]==0.6.2",
|
||||
]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.6.1"
|
||||
__version__ = "0.6.2"
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
"""Repair mental_models.subtype on databases stuck at m3rg3h3ad5f6
|
||||
|
||||
Three production deployments reported `column "subtype" of relation
|
||||
"mental_models" does not exist` on `create_mental_model` even after their
|
||||
container reported `Database migrations completed successfully` and
|
||||
`alembic_version` advanced to `m3rg3h3ad5f6` (see issue #1553, #1553#1
|
||||
confirmations from @4Lienau and @khanhduyvt0101).
|
||||
|
||||
Both `h3c4d5e6f7g8_mental_models_v4` and `d5y6z7a8b9c0_backfill_mental_models_subtype`
|
||||
were meant to ensure `subtype` exists, but on databases that came through the
|
||||
`reflections -> mental_models` rename chain *and* whose alembic_version
|
||||
advanced past `d5y6z7a8b9c0` along an alternate path during the divergent-heads
|
||||
reorganization, neither column-add actually fired. The result is a head-tagged
|
||||
database with a v3-shaped `mental_models` table missing six columns:
|
||||
``subtype``, ``description``, ``entity_id``, ``observations``, ``links``,
|
||||
``last_updated``.
|
||||
|
||||
This migration sits at the current head (`m3rg3h3ad5f6`) so every affected
|
||||
deployment will pick it up on next container start. It mirrors the column-add
|
||||
block from `d5y6z7a8b9c0_backfill_mental_models_subtype` using
|
||||
``ADD COLUMN IF NOT EXISTS`` so it is a no-op on databases where the columns
|
||||
are already present.
|
||||
|
||||
Revision ID: 86f7a033d372
|
||||
Revises: m3rg3h3ad5f6
|
||||
Create Date: 2026-05-14
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "86f7a033d372"
|
||||
down_revision: str | Sequence[str] | None = "m3rg3h3ad5f6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
"""Idempotently ensure mental_models has the v4 column set.
|
||||
|
||||
Safe to re-apply on databases that already received the columns via
|
||||
`h3c4d5e6f7g8_mental_models_v4` or `d5y6z7a8b9c0_backfill_mental_models_subtype` —
|
||||
every column-add uses ``IF NOT EXISTS`` and the constraint is recreated
|
||||
from scratch with the canonical v4 allowlist.
|
||||
"""
|
||||
schema = _pg_schema_prefix()
|
||||
bare_schema = schema.strip(".").strip('"') if schema else ""
|
||||
schema_clause = f"AND table_schema = '{bare_schema}'" if bare_schema else ""
|
||||
|
||||
# Wrapped in a DO block so the existence check skips databases that
|
||||
# predate the reflections -> mental_models rename chain (no table to
|
||||
# repair). On those, every ALTER below would error.
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'mental_models'
|
||||
{schema_clause}
|
||||
) THEN
|
||||
-- Add the six v4 columns idempotently.
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS subtype VARCHAR(32) NOT NULL DEFAULT 'structural';
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS entity_id UUID;
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS observations JSONB DEFAULT '{{"observations": []}}'::jsonb;
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS links VARCHAR[];
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS last_updated TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- Recreate the CHECK constraint with the canonical v4 allowlist.
|
||||
-- Existing rows with subtype = 'directive' (possible on databases
|
||||
-- that ran the o0j1k2l3m4n5 directive-only path) are rewritten to
|
||||
-- 'structural' first so the constraint add succeeds.
|
||||
UPDATE {schema}mental_models SET subtype = 'structural' WHERE subtype = 'directive';
|
||||
|
||||
ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype;
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD CONSTRAINT ck_mental_models_subtype
|
||||
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mental_models_subtype
|
||||
ON {schema}mental_models(bank_id, subtype);
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
"""No-op: dropping these columns would corrupt v4 application code."""
|
||||
pass
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PG-only: Oracle's baseline (o1a2b3c4d5e6) creates mental_models with its
|
||||
# own subtype shape (chk_mm_subtype IN ('directive', 'pinned')) and a
|
||||
# different table topology, so this PG-shaped repair does not apply.
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
@@ -471,6 +471,7 @@ PROVIDER_DEFAULT_MODELS = {
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"deepseek": "deepseek-v4-flash",
|
||||
"zai": "glm-4.5-flash",
|
||||
"opencode-go": "deepseek-v4-flash",
|
||||
"ollama": "gemma3:12b",
|
||||
"llamacpp": "gemma-4-e2b-it",
|
||||
"lmstudio": "local-model",
|
||||
|
||||
@@ -318,6 +318,7 @@ def create_llm_provider(
|
||||
"volcano",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"opencode-go",
|
||||
):
|
||||
return OpenAICompatibleLLM(
|
||||
provider=provider,
|
||||
@@ -425,6 +426,7 @@ class LLMProvider:
|
||||
"volcano",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"opencode-go",
|
||||
]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
||||
@@ -445,6 +447,8 @@ class LLMProvider:
|
||||
self.base_url = "https://openrouter.ai/api/v1"
|
||||
elif self.provider == "zai":
|
||||
self.base_url = "https://api.z.ai/api/coding/paas/v4"
|
||||
elif self.provider == "opencode-go":
|
||||
self.base_url = "https://opencode.ai/zen/go/v1"
|
||||
|
||||
# Prepare Vertex AI config (if applicable)
|
||||
vertexai_project_id = None
|
||||
@@ -836,7 +840,6 @@ class LLMProvider:
|
||||
def from_env(cls) -> "LLMProvider":
|
||||
"""Create provider from environment variables using config.py constants."""
|
||||
from ..config import (
|
||||
DEFAULT_LLM_MODEL,
|
||||
DEFAULT_LLM_PROVIDER,
|
||||
ENV_LLM_API_KEY,
|
||||
ENV_LLM_BASE_URL,
|
||||
@@ -844,6 +847,7 @@ class LLMProvider:
|
||||
ENV_LLM_EXTRA_BODY,
|
||||
ENV_LLM_MODEL,
|
||||
ENV_LLM_PROVIDER,
|
||||
_get_default_model_for_provider,
|
||||
)
|
||||
|
||||
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
|
||||
@@ -857,7 +861,7 @@ class LLMProvider:
|
||||
)
|
||||
|
||||
base_url = os.getenv(ENV_LLM_BASE_URL, "")
|
||||
model = os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL)
|
||||
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
|
||||
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
|
||||
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
|
||||
|
||||
|
||||
@@ -4,14 +4,26 @@ OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
|
||||
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
|
||||
without separate OpenAI Platform API credits. It uses OAuth tokens from
|
||||
~/.codex/auth.json and communicates with the ChatGPT backend API.
|
||||
|
||||
Tokens are refreshed automatically: the provider decodes the access_token
|
||||
JWT's ``exp`` claim and proactively refreshes via
|
||||
``POST https://auth.openai.com/oauth/token`` ~60s before expiry. It also
|
||||
reactively refreshes once on a 401/403 from the Codex backend before giving
|
||||
up. The refresh request shape mirrors the canonical ``@openai/codex`` CLI
|
||||
implementation (codex-rs/login/src/auth/manager.rs on github.com/openai/codex)
|
||||
so that future server-side changes affect both clients identically.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -24,6 +36,36 @@ from hindsight_api.metrics import get_metrics_collector
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# OAuth refresh endpoint and client id, mirrored from the canonical
|
||||
# ``@openai/codex`` CLI (codex-rs/login/src/auth/manager.rs on
|
||||
# github.com/openai/codex). The endpoint is overridable via env var so that
|
||||
# future Codex changes or staging environments can be pointed at without a
|
||||
# code change — same env var name the upstream CLI uses.
|
||||
_CODEX_REFRESH_TOKEN_URL = os.environ.get("CODEX_REFRESH_TOKEN_URL_OVERRIDE", "https://auth.openai.com/oauth/token")
|
||||
_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
|
||||
# Proactively refresh this many seconds before the JWT ``exp`` claim. The
|
||||
# upstream Codex CLI uses no skew (it refreshes at ``exp <= now``); the
|
||||
# extra window reduces races where a request leaves the client with a token
|
||||
# that the server has already declared expired by the time it arrives.
|
||||
_CODEX_TOKEN_REFRESH_SKEW_SECONDS = 60
|
||||
|
||||
# OAuth error codes that the refresh endpoint returns when the refresh_token
|
||||
# itself is no longer usable. These are terminal — retrying refresh will not
|
||||
# succeed; the user must re-run ``codex auth login``.
|
||||
_CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
|
||||
{"refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated"}
|
||||
)
|
||||
|
||||
|
||||
class CodexRefreshExpiredError(RuntimeError):
|
||||
"""Raised when the Codex refresh_token itself is no longer valid.
|
||||
|
||||
The user must re-run ``codex auth login`` to obtain new credentials.
|
||||
Callers should surface a clear remediation message and stop retrying.
|
||||
"""
|
||||
|
||||
|
||||
class CodexLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider using OpenAI Codex OAuth authentication.
|
||||
@@ -44,9 +86,19 @@ class CodexLLM(LLMInterface):
|
||||
"""Initialize Codex LLM provider."""
|
||||
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
||||
|
||||
# Path is fixed at ~/.codex/auth.json — matches the upstream CLI.
|
||||
# Storing it on self lets the refresh path re-read after another
|
||||
# process (e.g. a sidecar) rotates the file out from under us.
|
||||
self._auth_file = Path.home() / ".codex" / "auth.json"
|
||||
|
||||
# Single-flight refresh lock. Multiple concurrent requests racing
|
||||
# toward an expired token should produce one network refresh, not N.
|
||||
self._auth_lock = asyncio.Lock()
|
||||
|
||||
# Load Codex OAuth credentials
|
||||
try:
|
||||
self.access_token, self.account_id = self._load_codex_auth()
|
||||
self.refresh_token = self._load_codex_refresh_token()
|
||||
logger.info(f"Loaded Codex OAuth credentials for account: {self.account_id}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
@@ -108,6 +160,290 @@ class CodexLLM(LLMInterface):
|
||||
|
||||
return access_token, account_id
|
||||
|
||||
def _load_codex_refresh_token(self) -> str | None:
|
||||
"""Load ``tokens.refresh_token`` from ``~/.codex/auth.json``.
|
||||
|
||||
Returns None when the auth file is unreadable or omits the field —
|
||||
the provider still functions as a one-shot loader in that case, it
|
||||
just can't refresh when the access_token expires. This deliberately
|
||||
does not raise so that ``__init__`` keeps the existing failure mode
|
||||
of raising only on missing ``access_token``.
|
||||
"""
|
||||
try:
|
||||
with open(self._auth_file) as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f"Codex auth file unreadable when loading refresh_token: {type(e).__name__}. "
|
||||
"Token refresh will not be available; the access_token in memory will be used until it expires."
|
||||
)
|
||||
return None
|
||||
return data.get("tokens", {}).get("refresh_token")
|
||||
|
||||
@staticmethod
|
||||
def _decode_jwt_exp_unixtime(token: str) -> int | None:
|
||||
"""Return the JWT ``exp`` claim as a unix timestamp, or None on parse failure.
|
||||
|
||||
ChatGPT/Codex access_tokens are JWTs whose payload includes ``exp``
|
||||
(RFC 7519). We need the expiry to schedule proactive refresh — the
|
||||
``auth.json`` file does not persist a separate ``expires_at`` field
|
||||
in the upstream CLI's shape, so decoding the JWT itself is the
|
||||
canonical way to know when the token is stale.
|
||||
|
||||
We do not verify the signature — the server is the source of truth
|
||||
on whether the token is actually accepted, and the only thing this
|
||||
method affects is the *timing* of refresh, not whether to trust the
|
||||
token contents.
|
||||
"""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
payload_b64 = parts[1]
|
||||
# JWT uses base64url without padding. Re-pad before decoding.
|
||||
padding = "=" * (-len(payload_b64) % 4)
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding)
|
||||
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
exp = payload.get("exp")
|
||||
return int(exp) if exp is not None else None
|
||||
except (ValueError, TypeError, json.JSONDecodeError, binascii.Error):
|
||||
return None
|
||||
|
||||
def _token_is_stale(self, skew_seconds: int = _CODEX_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
|
||||
"""True when the cached access_token is past expiry (with skew).
|
||||
|
||||
Returns False when expiry cannot be determined — we'd rather use a
|
||||
possibly-expired token and recover via the reactive 401 path than
|
||||
refresh aggressively on every request when ``exp`` parsing fails.
|
||||
"""
|
||||
exp = self._decode_jwt_exp_unixtime(self.access_token)
|
||||
if exp is None:
|
||||
return False
|
||||
return exp <= int(time.time()) + skew_seconds
|
||||
|
||||
def _persist_auth_atomic(self, updated_tokens: dict[str, Any]) -> None:
|
||||
"""Write the rotated tokens back to ``~/.codex/auth.json`` atomically.
|
||||
|
||||
Strategy: re-read the on-disk auth.json (so we don't clobber fields
|
||||
another process may have added), patch ``tokens.*`` and
|
||||
``last_refresh``, write to a tempfile in the same directory with
|
||||
mode 0600, then ``os.replace`` onto the target. ``os.replace`` is
|
||||
atomic within the same filesystem on POSIX and Windows, so a
|
||||
concurrent reader will see either the old file or the fully-written
|
||||
new file — never a partial truncate, which is the upstream CLI's
|
||||
worst-case race.
|
||||
|
||||
On non-Unix platforms the chmod is a best-effort no-op; the parent
|
||||
directory permissions still bound access.
|
||||
"""
|
||||
current: dict[str, Any]
|
||||
try:
|
||||
with open(self._auth_file) as f:
|
||||
loaded = json.load(f)
|
||||
# auth.json should always be a JSON object at the top level; if
|
||||
# someone has hand-edited it into a non-object shape, fall back
|
||||
# to the minimal default rather than crashing the refresh path.
|
||||
current = loaded if isinstance(loaded, dict) else {"auth_mode": "chatgpt", "tokens": {}}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# If the file became unreadable between our last read and now,
|
||||
# construct a minimal shape rather than refusing to persist.
|
||||
current = {"auth_mode": "chatgpt", "tokens": {}}
|
||||
|
||||
existing_tokens = current.get("tokens")
|
||||
tokens: dict[str, Any] = existing_tokens if isinstance(existing_tokens, dict) else {}
|
||||
for key in ("access_token", "refresh_token", "id_token", "account_id"):
|
||||
if key in updated_tokens and updated_tokens[key] is not None:
|
||||
tokens[key] = updated_tokens[key]
|
||||
current["tokens"] = tokens
|
||||
current["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# Write to a sibling tempfile so the rename is same-filesystem.
|
||||
parent = self._auth_file.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".auth.", suffix=".json.tmp", dir=str(parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(current, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
try:
|
||||
os.chmod(tmp_path, 0o600)
|
||||
except OSError:
|
||||
pass # best-effort on platforms that don't support chmod
|
||||
os.replace(tmp_path, self._auth_file)
|
||||
except Exception:
|
||||
# Clean up the orphaned tempfile if rename fails.
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
async def _refresh_oauth_tokens(self, reason: str = "", *, force: bool = False) -> None:
|
||||
"""Refresh the OAuth access_token using the stored refresh_token.
|
||||
|
||||
Single-flight: serialized through ``self._auth_lock`` so concurrent
|
||||
callers produce one network request. The first caller refreshes; the
|
||||
rest wake up and observe that either (a) the in-memory token is no
|
||||
longer stale (proactive case) or (b) the in-memory token has changed
|
||||
since they entered (reactive case), and return without re-refreshing.
|
||||
|
||||
Args:
|
||||
reason: Free-form string included in log lines for diagnostics.
|
||||
force: When True, refresh even if the JWT exp claim looks fresh.
|
||||
Used by the reactive 401 path — the server rejected the
|
||||
token, so we cannot trust the JWT's self-reported expiry.
|
||||
|
||||
Raises:
|
||||
CodexRefreshExpiredError: when the server returns a terminal
|
||||
error code (refresh_token_expired/reused/invalidated) or any
|
||||
401 on the refresh endpoint itself.
|
||||
RuntimeError: for other refresh failures (network, 5xx, etc.).
|
||||
"""
|
||||
# Capture the token we'd be refreshing BEFORE acquiring the lock so
|
||||
# that we can detect mid-wait rotation by another coroutine.
|
||||
token_before_lock = self.access_token
|
||||
async with self._auth_lock:
|
||||
if force:
|
||||
# Reactive: skip only if another coroutine already rotated
|
||||
# the token while we were waiting on the lock.
|
||||
if self.access_token != token_before_lock:
|
||||
return
|
||||
else:
|
||||
# Proactive: skip if the token is no longer stale (the
|
||||
# canonical "another coroutine refreshed first" check).
|
||||
if not self._token_is_stale():
|
||||
return
|
||||
|
||||
if not self.refresh_token:
|
||||
raise RuntimeError(
|
||||
"Codex access_token is expired but no refresh_token is available. "
|
||||
"Run 'codex auth login' to re-authenticate."
|
||||
)
|
||||
|
||||
log_reason = f" ({reason})" if reason else ""
|
||||
logger.info(f"Refreshing Codex OAuth access_token{log_reason}")
|
||||
|
||||
request_body = {
|
||||
"client_id": _CODEX_CLIENT_ID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.refresh_token,
|
||||
}
|
||||
try:
|
||||
response = await self._client.post(
|
||||
_CODEX_REFRESH_TOKEN_URL,
|
||||
json=request_body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=30.0,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise RuntimeError(f"Codex OAuth refresh network error: {type(e).__name__}") from e
|
||||
|
||||
if response.status_code == 401:
|
||||
# Classify by ``error.code`` (or top-level ``error`` string) — same
|
||||
# mapping as the upstream Rust CLI's request_chatgpt_token_refresh.
|
||||
error_code = self._extract_oauth_error_code(response)
|
||||
if error_code in _CODEX_TERMINAL_REFRESH_ERROR_CODES:
|
||||
raise CodexRefreshExpiredError(
|
||||
f"Codex refresh_token is permanently invalid (error.code={error_code}). "
|
||||
"Run 'codex auth login' to re-authenticate."
|
||||
)
|
||||
# Unknown 401 — treat as terminal too, matching the upstream classification.
|
||||
raise CodexRefreshExpiredError(
|
||||
f"Codex OAuth refresh returned 401 with unrecognized error code "
|
||||
f"({error_code or 'none'}). Run 'codex auth login' to re-authenticate."
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
# 5xx and other 4xx are transient/retryable from the caller's
|
||||
# perspective; surface as RuntimeError without leaking the
|
||||
# request body in logs.
|
||||
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
|
||||
|
||||
try:
|
||||
body = response.json()
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
|
||||
|
||||
new_access = body.get("access_token")
|
||||
if not new_access:
|
||||
raise RuntimeError("Codex OAuth refresh returned no access_token")
|
||||
|
||||
# The refresh_token may rotate on each refresh — adopt the new
|
||||
# one if the server sent it, otherwise keep the existing.
|
||||
new_refresh = body.get("refresh_token") or self.refresh_token
|
||||
new_id_token = body.get("id_token")
|
||||
|
||||
# Update in-memory state first so callers waiting on the lock
|
||||
# see fresh credentials immediately, even if disk write fails.
|
||||
self.access_token = new_access
|
||||
self.refresh_token = new_refresh
|
||||
|
||||
persisted = {
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
}
|
||||
if new_id_token:
|
||||
persisted["id_token"] = new_id_token
|
||||
|
||||
try:
|
||||
self._persist_auth_atomic(persisted)
|
||||
except OSError as e:
|
||||
# In-memory creds are valid; warn but don't fail the request
|
||||
# path. Future process starts will fall back to the stale
|
||||
# on-disk auth.json and immediately refresh.
|
||||
logger.warning(
|
||||
f"Codex OAuth refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
|
||||
"In-memory credentials are up to date; on-disk file is stale."
|
||||
)
|
||||
|
||||
logger.info("Codex OAuth access_token refreshed successfully")
|
||||
|
||||
@staticmethod
|
||||
def _extract_oauth_error_code(response: "httpx.Response") -> str | None:
|
||||
"""Pull the OAuth error code out of a 4xx response body, if present.
|
||||
|
||||
The refresh endpoint returns shapes like
|
||||
``{"error": "...", "error_code": "..."}`` or
|
||||
``{"error": {"code": "..."}}``. We don't fail the call if the body
|
||||
is unparseable — the caller falls back to a generic "unknown" error.
|
||||
"""
|
||||
try:
|
||||
body = response.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
# Shape 1: error is a nested object with "code"
|
||||
err = body.get("error")
|
||||
if isinstance(err, dict):
|
||||
code = err.get("code")
|
||||
if isinstance(code, str):
|
||||
return code
|
||||
# Shape 2: top-level error_code string
|
||||
code = body.get("error_code")
|
||||
if isinstance(code, str):
|
||||
return code
|
||||
# Shape 3: error is itself a string code
|
||||
if isinstance(err, str):
|
||||
return err
|
||||
return None
|
||||
|
||||
async def _ensure_fresh_token(self) -> None:
|
||||
"""Refresh the access_token proactively if it is near or past expiry.
|
||||
|
||||
Called at the top of every API-bound method. Cheap when the token is
|
||||
fresh (just decodes the JWT exp claim and returns).
|
||||
"""
|
||||
if self._token_is_stale():
|
||||
try:
|
||||
await self._refresh_oauth_tokens(reason="proactive (token near expiry)")
|
||||
except CodexRefreshExpiredError:
|
||||
# Surface to the caller as the same RuntimeError shape the
|
||||
# request loop has historically raised, so existing error
|
||||
# handling paths keep working.
|
||||
raise
|
||||
|
||||
def _map_reasoning_effort(self, effort: str) -> str:
|
||||
"""
|
||||
Map standard reasoning effort to Codex reasoning summary format.
|
||||
@@ -189,6 +525,15 @@ class CodexLLM(LLMInterface):
|
||||
"""Make API call to Codex backend with SSE streaming."""
|
||||
start_time = time.time()
|
||||
|
||||
# Proactively refresh the OAuth access_token if it's near expiry.
|
||||
# Cheap when fresh: a JWT exp decode + comparison.
|
||||
await self._ensure_fresh_token()
|
||||
|
||||
# Tracks whether we've already attempted a reactive refresh in
|
||||
# response to a 401 from the backend. Set once on the first auth
|
||||
# failure so we retry exactly once after refresh, not in a loop.
|
||||
attempted_refresh_after_auth_error = False
|
||||
|
||||
# Prepare system instructions
|
||||
system_instruction = ""
|
||||
user_messages = []
|
||||
@@ -244,7 +589,12 @@ class CodexLLM(LLMInterface):
|
||||
url = f"{self.base_url}/codex/responses"
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
# Manual attempt tracking instead of ``for attempt in range(...)`` so
|
||||
# that the reactive-refresh path can retry once without consuming a
|
||||
# normal-retry budget slot. The refresh-retry is conceptually a
|
||||
# separate auth-recovery attempt that shouldn't compete with backoff.
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
response.raise_for_status()
|
||||
@@ -269,6 +619,7 @@ class CodexLLM(LLMInterface):
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = e
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
|
||||
@@ -332,8 +683,38 @@ class CodexLLM(LLMInterface):
|
||||
last_exception = e
|
||||
status_code = e.response.status_code
|
||||
|
||||
# Fast fail on auth errors
|
||||
# Auth error: try one OAuth refresh + retry before giving up.
|
||||
# The proactive refresh at the top of this method catches most
|
||||
# expiries, but a token can also become invalid mid-request if
|
||||
# another process rotates auth.json out from under us, or if
|
||||
# the JWT exp claim is unparseable and we never knew it was
|
||||
# stale. Reactive refresh is the safety net.
|
||||
if status_code in (401, 403):
|
||||
if not attempted_refresh_after_auth_error:
|
||||
attempted_refresh_after_auth_error = True
|
||||
try:
|
||||
await self._refresh_oauth_tokens(
|
||||
reason=f"reactive (HTTP {status_code} from codex backend)",
|
||||
force=True,
|
||||
)
|
||||
# Rebuild the Authorization header with the new
|
||||
# token and retry without consuming a normal-retry
|
||||
# budget slot — this is a dedicated auth-recovery
|
||||
# attempt that shouldn't compete with backoff.
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
logger.info("Codex auth refreshed after auth error; retrying request once")
|
||||
continue
|
||||
except CodexRefreshExpiredError as refresh_err:
|
||||
logger.error("Codex refresh_token is permanently invalid; cannot recover from auth error")
|
||||
raise RuntimeError(
|
||||
"Codex authentication failed and the refresh_token is no longer valid.\n"
|
||||
"Run 'codex auth login' to re-authenticate."
|
||||
) from refresh_err
|
||||
except Exception as refresh_err:
|
||||
logger.error(
|
||||
f"Codex token refresh attempt failed: {type(refresh_err).__name__}: {refresh_err}"
|
||||
)
|
||||
# Fall through to the original raise below.
|
||||
logger.error(f"Codex auth error (HTTP {status_code}): {e.response.text[:200]}")
|
||||
raise RuntimeError(
|
||||
"Codex authentication failed. Your OAuth token may have expired.\n"
|
||||
@@ -349,6 +730,7 @@ class CodexLLM(LLMInterface):
|
||||
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
@@ -362,6 +744,7 @@ class CodexLLM(LLMInterface):
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Codex connection error after {max_retries + 1} attempts: {e}")
|
||||
@@ -462,6 +845,11 @@ class CodexLLM(LLMInterface):
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Proactively refresh the OAuth access_token if it's near expiry.
|
||||
# Same rationale as in ``call()`` — keeps the request from leaving
|
||||
# the client carrying a token that's already past ``exp``.
|
||||
await self._ensure_fresh_token()
|
||||
|
||||
# Prepare system instructions
|
||||
system_instruction = ""
|
||||
user_messages = []
|
||||
@@ -534,9 +922,39 @@ class CodexLLM(LLMInterface):
|
||||
# Debug logging for troubleshooting
|
||||
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
|
||||
|
||||
# One reactive refresh attempt on auth failure, mirroring call().
|
||||
# ``call_with_tools`` doesn't have a retry loop, so we hand-roll a
|
||||
# single retry after refreshing the token. Any non-auth error still
|
||||
# surfaces immediately to keep behavior identical for callers.
|
||||
attempted_refresh_after_auth_error = False
|
||||
|
||||
try:
|
||||
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
|
||||
if response.status_code in (401, 403) and not attempted_refresh_after_auth_error:
|
||||
attempted_refresh_after_auth_error = True
|
||||
try:
|
||||
await self._refresh_oauth_tokens(
|
||||
reason=f"reactive (HTTP {response.status_code} from codex backend in call_with_tools)",
|
||||
force=True,
|
||||
)
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
logger.info("Codex auth refreshed after auth error; retrying tool-call request once")
|
||||
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
except CodexRefreshExpiredError as refresh_err:
|
||||
logger.error(
|
||||
"Codex refresh_token is permanently invalid; cannot recover from auth error in tool-call path"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Codex authentication failed and the refresh_token is no longer valid.\n"
|
||||
"Run 'codex auth login' to re-authenticate."
|
||||
) from refresh_err
|
||||
except Exception as refresh_err:
|
||||
logger.error(
|
||||
f"Codex token refresh attempt failed in tool-call path: {type(refresh_err).__name__}: {refresh_err}"
|
||||
)
|
||||
# Fall through to the normal error path below.
|
||||
|
||||
# Log response details on error
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, and DeepSeek.
|
||||
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, DeepSeek,
|
||||
and Opencode Go.
|
||||
|
||||
This provider handles all OpenAI API-compatible models including:
|
||||
- OpenAI: GPT-4, GPT-4o, GPT-5, o1, o3 (reasoning models)
|
||||
@@ -8,6 +9,7 @@ This provider handles all OpenAI API-compatible models including:
|
||||
- LMStudio: Local models with OpenAI-compatible API
|
||||
- MiniMax: MiniMax-M2.7 models with 1M context window
|
||||
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via api.deepseek.com
|
||||
- Opencode Go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
|
||||
|
||||
Features:
|
||||
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
|
||||
@@ -232,6 +234,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
- LMStudio: Local models with OpenAI-compatible API
|
||||
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
|
||||
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via https://api.deepseek.com
|
||||
- opencode-go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -250,7 +253,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
Initialize OpenAI-compatible LLM provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name ("openai", "groq", "ollama", "lmstudio").
|
||||
provider: Provider name ("openai", "groq", "ollama", "lmstudio", "opencode-go", etc.).
|
||||
api_key: API key (optional for ollama/lmstudio).
|
||||
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
|
||||
model: Model name.
|
||||
@@ -274,6 +277,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
"volcano",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"opencode-go",
|
||||
]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
|
||||
@@ -294,13 +298,27 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
self.base_url = "https://openrouter.ai/api/v1"
|
||||
elif self.provider == "zai":
|
||||
self.base_url = "https://api.z.ai/api/coding/paas/v4"
|
||||
elif self.provider == "opencode-go":
|
||||
self.base_url = "https://opencode.ai/zen/go/v1"
|
||||
|
||||
# For ollama/lmstudio, use dummy key if not provided
|
||||
if self.provider in ("ollama", "lmstudio") and not self.api_key:
|
||||
self.api_key = "local"
|
||||
|
||||
# Validate API key for cloud providers
|
||||
if self.provider in ("openai", "groq", "minimax", "deepseek", "openrouter", "zai") and not self.api_key:
|
||||
if (
|
||||
self.provider
|
||||
in (
|
||||
"openai",
|
||||
"groq",
|
||||
"minimax",
|
||||
"deepseek",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"opencode-go",
|
||||
)
|
||||
and not self.api_key
|
||||
):
|
||||
raise ValueError(f"API key is required for {self.provider}")
|
||||
|
||||
# Service tier configuration (from config, not env vars)
|
||||
@@ -561,10 +579,11 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
)
|
||||
|
||||
# Strip reasoning model thinking tags
|
||||
# Supports: <think>, <thinking>, <reasoning>, |startthink|/|endthink|
|
||||
# Supports: <think>, <thinking>, <thought>, <reasoning>, |startthink|/|endthink|
|
||||
original_len = len(content)
|
||||
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
|
||||
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
|
||||
content = re.sub(r"<thought>.*?</thought>", "", content, flags=re.DOTALL)
|
||||
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
|
||||
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
|
||||
content = content.strip()
|
||||
|
||||
@@ -14,8 +14,6 @@ import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
import tiktoken
|
||||
|
||||
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
|
||||
from .prompts import (
|
||||
_extract_directive_rules,
|
||||
@@ -23,6 +21,7 @@ from .prompts import (
|
||||
build_final_system_prompt,
|
||||
build_system_prompt_for_tools,
|
||||
)
|
||||
from .tokenization import count_cl100k_tokens
|
||||
from .tools_schema import get_reflect_tools
|
||||
|
||||
|
||||
@@ -266,25 +265,22 @@ OUTPUT:"""
|
||||
return None, 0, 0
|
||||
|
||||
|
||||
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
|
||||
|
||||
|
||||
def _count_messages_tokens(messages: list[dict[str, Any]]) -> int:
|
||||
"""Estimate the token count of the messages list using cl100k_base encoding."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content") or ""
|
||||
if isinstance(content, str):
|
||||
total += len(_TIKTOKEN_ENCODING.encode(content))
|
||||
total += count_cl100k_tokens(content)
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||
total += len(_TIKTOKEN_ENCODING.encode(part["text"]))
|
||||
total += count_cl100k_tokens(part["text"])
|
||||
# Tool call arguments and results also count
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict):
|
||||
func = tc.get("function", {})
|
||||
total += len(_TIKTOKEN_ENCODING.encode(func.get("arguments", "")))
|
||||
total += count_cl100k_tokens(func.get("arguments", ""))
|
||||
return total
|
||||
|
||||
|
||||
@@ -672,7 +668,7 @@ async def run_reflect_agent(
|
||||
# must respect max_tokens like the forced-final paths do. If it
|
||||
# overshoots, run one extra capped call to rewrite it within
|
||||
# the cap.
|
||||
if max_tokens is not None and len(_TIKTOKEN_ENCODING.encode(answer)) > max_tokens:
|
||||
if max_tokens is not None and count_cl100k_tokens(answer) > max_tokens:
|
||||
rewrite_start = time.time()
|
||||
rewritten, rewrite_usage = await llm_config.call(
|
||||
messages=[
|
||||
|
||||
@@ -10,9 +10,7 @@ The reflect agent uses hierarchical retrieval:
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import tiktoken
|
||||
|
||||
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
|
||||
from .tokenization import count_cl100k_tokens
|
||||
|
||||
# Fraction of max_context_tokens reserved for tool results in the final synthesis prompt.
|
||||
# The remainder covers the system prompt, question, bank context, and output tokens.
|
||||
@@ -453,7 +451,7 @@ def build_final_prompt(
|
||||
except (TypeError, ValueError):
|
||||
output_str = str(output)
|
||||
block = f"\n### From {tool}:\n```json\n{output_str}\n```"
|
||||
block_tokens = len(_TIKTOKEN_ENCODING.encode(block))
|
||||
block_tokens = count_cl100k_tokens(block)
|
||||
if block_tokens > token_budget:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Token counting helpers for reflect prompts and agent control flow."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
import tiktoken
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_cl100k_base_encoding() -> tiktoken.Encoding:
|
||||
# tiktoken downloads this encoding on first lookup when it is not cached.
|
||||
# Keep the lookup lazy so importing hindsight_api does not depend on network access.
|
||||
return tiktoken.get_encoding("cl100k_base")
|
||||
|
||||
|
||||
def count_cl100k_tokens(text: str) -> int:
|
||||
"""Return the number of cl100k_base tokens in text."""
|
||||
return len(_get_cl100k_base_encoding().encode(text))
|
||||
@@ -191,21 +191,21 @@ class TagGroupLeaf(BaseModel):
|
||||
class TagGroupAnd(BaseModel):
|
||||
"""Compound AND group: all child filters must match."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
filters: list[TagGroup] = Field(alias="and")
|
||||
|
||||
|
||||
class TagGroupOr(BaseModel):
|
||||
"""Compound OR group: at least one child filter must match."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
filters: list[TagGroup] = Field(alias="or")
|
||||
|
||||
|
||||
class TagGroupNot(BaseModel):
|
||||
"""Compound NOT group: child filter must NOT match."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
filter: TagGroup = Field(alias="not")
|
||||
|
||||
|
||||
|
||||
@@ -478,6 +478,9 @@ def _migrate_table_embedding_dimension(
|
||||
logger.info(f"Altering {table_name}.embedding column dimension from {current_dim} to {required_dimension}")
|
||||
|
||||
# Drop existing vector index (works for HNSW, DiskANN, vchordrq, and ScaNN)
|
||||
# The EXCEPTION block handles 'could not open relation with OID' errors that
|
||||
# occur when concurrent sessions drop schemas (e.g. pytest-xdist workers),
|
||||
# invalidating pg_indexes OID references mid-cursor-iteration.
|
||||
conn.execute(
|
||||
text(f"""
|
||||
DO $$
|
||||
@@ -492,6 +495,9 @@ def _migrate_table_embedding_dimension(
|
||||
LOOP
|
||||
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
|
||||
END LOOP;
|
||||
EXCEPTION WHEN internal_error THEN
|
||||
-- Stale OID from concurrent schema drop; nothing to drop anyway
|
||||
NULL;
|
||||
END $$;
|
||||
""")
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api-slim"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Tests for Codex OAuth token refresh (issue #1637).
|
||||
|
||||
The Codex provider was originally a startup-only credential loader: it read
|
||||
``~/.codex/auth.json`` once and used the cached access_token forever. These
|
||||
tests pin the new automatic-refresh behavior:
|
||||
|
||||
- ``refresh_token`` is now actually loaded from auth.json.
|
||||
- The provider proactively refreshes ~60s before the JWT ``exp`` claim.
|
||||
- It reactively refreshes once on a 401/403 from the Codex backend.
|
||||
- The OAuth refresh request shape mirrors the canonical ``@openai/codex``
|
||||
CLI (POST https://auth.openai.com/oauth/token, JSON body with hardcoded
|
||||
client_id, grant_type=refresh_token).
|
||||
- Terminal error codes (refresh_token_expired/reused/invalidated) raise a
|
||||
permanent error and do not loop.
|
||||
- Concurrent callers serialize through a single-flight lock.
|
||||
- ``auth.json`` is persisted atomically via tempfile+rename with mode 0600.
|
||||
|
||||
Tests construct ``CodexLLM`` with ``_load_codex_auth`` mocked, then drive
|
||||
JWT exp / network / persistence paths through targeted patches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.providers.codex_llm import (
|
||||
_CODEX_CLIENT_ID,
|
||||
_CODEX_REFRESH_TOKEN_URL,
|
||||
CodexLLM,
|
||||
CodexRefreshExpiredError,
|
||||
)
|
||||
|
||||
|
||||
def _make_jwt(exp_unixtime: int | None) -> str:
|
||||
"""Build a minimal JWT-shaped token with the given ``exp`` claim.
|
||||
|
||||
Signature segment is a placeholder — we don't verify, we only decode
|
||||
the payload to read ``exp``.
|
||||
"""
|
||||
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode()
|
||||
payload_dict: dict[str, object] = {}
|
||||
if exp_unixtime is not None:
|
||||
payload_dict["exp"] = exp_unixtime
|
||||
payload = base64.urlsafe_b64encode(json.dumps(payload_dict).encode()).rstrip(b"=").decode()
|
||||
signature = "sig"
|
||||
return f"{header}.{payload}.{signature}"
|
||||
|
||||
|
||||
def _build_llm(refresh_token: str | None = "rt-initial", access_token: str | None = None) -> CodexLLM:
|
||||
"""Construct a CodexLLM with patched auth-file reads."""
|
||||
if access_token is None:
|
||||
access_token = _make_jwt(int(time.time()) + 3600) # fresh by default
|
||||
with (
|
||||
patch.object(CodexLLM, "_load_codex_auth", return_value=(access_token, "acct-123")),
|
||||
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=refresh_token),
|
||||
):
|
||||
return CodexLLM(
|
||||
provider="openai-codex",
|
||||
api_key="ignored",
|
||||
base_url="https://chatgpt.com/backend-api",
|
||||
model="gpt-5.4-mini",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT exp decode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jwt_exp_decode_returns_int_for_valid_token():
|
||||
token = _make_jwt(1_800_000_000)
|
||||
assert CodexLLM._decode_jwt_exp_unixtime(token) == 1_800_000_000
|
||||
|
||||
|
||||
def test_jwt_exp_decode_returns_none_when_exp_missing():
|
||||
token = _make_jwt(None)
|
||||
assert CodexLLM._decode_jwt_exp_unixtime(token) is None
|
||||
|
||||
|
||||
def test_jwt_exp_decode_returns_none_for_malformed_token():
|
||||
assert CodexLLM._decode_jwt_exp_unixtime("not.a.real.jwt") is None
|
||||
assert CodexLLM._decode_jwt_exp_unixtime("only-one-segment") is None
|
||||
assert CodexLLM._decode_jwt_exp_unixtime("a.!!notbase64!!.c") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Staleness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_token_is_stale_true_when_expired():
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(access_token=expired)
|
||||
assert llm._token_is_stale() is True
|
||||
|
||||
|
||||
def test_token_is_stale_true_within_skew_window():
|
||||
# 30s before expiry, default skew is 60s → should be considered stale.
|
||||
soon = _make_jwt(int(time.time()) + 30)
|
||||
llm = _build_llm(access_token=soon)
|
||||
assert llm._token_is_stale() is True
|
||||
|
||||
|
||||
def test_token_is_stale_false_when_far_from_expiry():
|
||||
far = _make_jwt(int(time.time()) + 3600)
|
||||
llm = _build_llm(access_token=far)
|
||||
assert llm._token_is_stale() is False
|
||||
|
||||
|
||||
def test_token_is_stale_false_when_exp_unparseable():
|
||||
# When we can't decide, we'd rather use a possibly-expired token and
|
||||
# recover via the reactive 401 path than refresh aggressively.
|
||||
llm = _build_llm(access_token="opaque-token-no-jwt-structure")
|
||||
assert llm._token_is_stale() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_token loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_token_loaded_from_auth_file(tmp_path: Path):
|
||||
auth_file = tmp_path / "auth.json"
|
||||
auth_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt-from-disk",
|
||||
"account_id": "acct",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
with patch.object(CodexLLM, "_load_codex_auth", return_value=("at", "acct")):
|
||||
llm = CodexLLM(
|
||||
provider="openai-codex",
|
||||
api_key="ignored",
|
||||
base_url="https://chatgpt.com/backend-api",
|
||||
model="gpt-5.4-mini",
|
||||
)
|
||||
# Now point the auth_file at our tmp file and reload.
|
||||
llm._auth_file = auth_file
|
||||
assert llm._load_codex_refresh_token() == "rt-from-disk"
|
||||
|
||||
|
||||
def test_refresh_token_returns_none_when_field_absent(tmp_path: Path):
|
||||
auth_file = tmp_path / "auth.json"
|
||||
auth_file.write_text(json.dumps({"auth_mode": "chatgpt", "tokens": {"access_token": "at"}}))
|
||||
llm = _build_llm()
|
||||
llm._auth_file = auth_file
|
||||
assert llm._load_codex_refresh_token() is None
|
||||
|
||||
|
||||
def test_refresh_token_returns_none_when_file_missing(tmp_path: Path):
|
||||
llm = _build_llm()
|
||||
llm._auth_file = tmp_path / "definitely-not-here.json"
|
||||
assert llm._load_codex_refresh_token() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomic persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_persist_auth_atomic_writes_mode_0600_and_preserves_fields(tmp_path: Path):
|
||||
auth_file = tmp_path / "auth.json"
|
||||
auth_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"OPENAI_API_KEY": None,
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": "old",
|
||||
"refresh_token": "rt-old",
|
||||
"account_id": "acct-keep",
|
||||
"id_token": {"email": "[email protected]"},
|
||||
},
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
llm = _build_llm()
|
||||
llm._auth_file = auth_file
|
||||
llm._persist_auth_atomic({"access_token": "new", "refresh_token": "rt-new"})
|
||||
|
||||
written = json.loads(auth_file.read_text())
|
||||
assert written["tokens"]["access_token"] == "new"
|
||||
assert written["tokens"]["refresh_token"] == "rt-new"
|
||||
# Untouched fields are preserved (account_id, id_token, auth_mode).
|
||||
assert written["tokens"]["account_id"] == "acct-keep"
|
||||
assert written["tokens"]["id_token"] == {"email": "[email protected]"}
|
||||
assert written["auth_mode"] == "chatgpt"
|
||||
# last_refresh got bumped to a new ISO-8601 UTC timestamp.
|
||||
assert written["last_refresh"] != "2026-01-01T00:00:00Z"
|
||||
assert written["last_refresh"].endswith("Z")
|
||||
|
||||
if sys.platform != "win32":
|
||||
mode = stat.S_IMODE(auth_file.stat().st_mode)
|
||||
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
|
||||
|
||||
|
||||
def test_persist_auth_atomic_does_not_leak_tempfile_on_success(tmp_path: Path):
|
||||
auth_file = tmp_path / "auth.json"
|
||||
auth_file.write_text(json.dumps({"tokens": {"access_token": "old"}}))
|
||||
|
||||
llm = _build_llm()
|
||||
llm._auth_file = auth_file
|
||||
llm._persist_auth_atomic({"access_token": "new"})
|
||||
|
||||
# No sibling tempfile should remain — atomic rename consumed it.
|
||||
siblings = [p.name for p in tmp_path.iterdir()]
|
||||
assert siblings == ["auth.json"], f"unexpected leftover files: {siblings}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _refresh_oauth_tokens — request shape, in-memory update, rotation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _refresh_response(status_code: int, body: dict | str) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.status_code = status_code
|
||||
if isinstance(body, dict):
|
||||
response.json.return_value = body
|
||||
response.text = json.dumps(body)
|
||||
else:
|
||||
response.json.side_effect = json.JSONDecodeError("nope", body, 0)
|
||||
response.text = body
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_sends_canonical_request_shape(tmp_path: Path):
|
||||
"""POST JSON body with client_id + grant_type=refresh_token + refresh_token."""
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt-current", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
|
||||
|
||||
fresh_access = _make_jwt(int(time.time()) + 3600)
|
||||
refresh_resp = _refresh_response(200, {"access_token": fresh_access, "refresh_token": "rt-rotated"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp) as mock_post:
|
||||
await llm._refresh_oauth_tokens()
|
||||
|
||||
call_args = mock_post.call_args
|
||||
assert call_args.args[0] == _CODEX_REFRESH_TOKEN_URL
|
||||
assert call_args.kwargs["headers"]["Content-Type"] == "application/json"
|
||||
assert call_args.kwargs["json"] == {
|
||||
"client_id": _CODEX_CLIENT_ID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "rt-current",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_updates_in_memory_credentials(tmp_path: Path):
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt-old", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-old"}}))
|
||||
|
||||
new_access = _make_jwt(int(time.time()) + 3600)
|
||||
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
|
||||
await llm._refresh_oauth_tokens()
|
||||
|
||||
assert llm.access_token == new_access
|
||||
assert llm.refresh_token == "rt-new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_keeps_existing_refresh_token_when_server_omits_one(tmp_path: Path):
|
||||
"""If the OAuth response has no ``refresh_token`` field, keep the one we have."""
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt-keep", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-keep"}}))
|
||||
|
||||
new_access = _make_jwt(int(time.time()) + 3600)
|
||||
refresh_resp = _refresh_response(200, {"access_token": new_access})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
|
||||
await llm._refresh_oauth_tokens()
|
||||
|
||||
assert llm.refresh_token == "rt-keep"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_raises_permanent_error_on_terminal_oauth_code(tmp_path: Path):
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
|
||||
|
||||
bad_resp = _refresh_response(401, {"error": {"code": "refresh_token_expired"}})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
|
||||
with pytest.raises(CodexRefreshExpiredError):
|
||||
await llm._refresh_oauth_tokens()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_raises_permanent_error_on_unknown_401(tmp_path: Path):
|
||||
"""Any 401 from the refresh endpoint is treated as permanent — matches upstream Rust classification."""
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
|
||||
|
||||
bad_resp = _refresh_response(401, {"error": "something_else"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
|
||||
with pytest.raises(CodexRefreshExpiredError):
|
||||
await llm._refresh_oauth_tokens()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_raises_runtime_error_on_5xx(tmp_path: Path):
|
||||
"""5xx is transient from the caller's perspective — surface as RuntimeError, not CodexRefreshExpiredError."""
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt-current", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
|
||||
|
||||
bad_resp = _refresh_response(503, "service unavailable")
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await llm._refresh_oauth_tokens()
|
||||
assert not isinstance(exc_info.value, CodexRefreshExpiredError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_does_not_log_token_values(tmp_path: Path, caplog):
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
secret_rt = "rt-DO-NOT-LEAK-THIS"
|
||||
llm = _build_llm(refresh_token=secret_rt, access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": secret_rt}}))
|
||||
|
||||
new_access = _make_jwt(int(time.time()) + 3600)
|
||||
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-also-secret"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
|
||||
with caplog.at_level("DEBUG"):
|
||||
await llm._refresh_oauth_tokens()
|
||||
|
||||
log_text = "\n".join(record.getMessage() for record in caplog.records)
|
||||
assert secret_rt not in log_text
|
||||
assert new_access not in log_text
|
||||
assert "rt-also-secret" not in log_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-flight under concurrent callers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_ensure_fresh_token_calls_produce_one_refresh(tmp_path: Path):
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt"}}))
|
||||
|
||||
new_access = _make_jwt(int(time.time()) + 3600)
|
||||
call_count = 0
|
||||
|
||||
async def fake_post(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# Simulate non-zero refresh latency so concurrent callers actually queue.
|
||||
await asyncio.sleep(0.01)
|
||||
return _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
|
||||
|
||||
with patch.object(llm._client, "post", new=fake_post):
|
||||
await asyncio.gather(*(llm._ensure_fresh_token() for _ in range(10)))
|
||||
|
||||
assert call_count == 1, f"expected 1 network refresh under contention, got {call_count}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactive 401 retry on the request path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
|
||||
"""A backend 401 triggers one refresh + retry instead of immediately raising."""
|
||||
fresh = _make_jwt(int(time.time()) + 3600) # not stale; the 401 is the trigger
|
||||
llm = _build_llm(refresh_token="rt", access_token=fresh)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
|
||||
|
||||
new_access = _make_jwt(int(time.time()) + 3600)
|
||||
|
||||
# First post → 401 (backend rejects the token). After refresh, second post → 200.
|
||||
success_resp = MagicMock()
|
||||
success_resp.status_code = 200
|
||||
success_resp.raise_for_status.return_value = None
|
||||
|
||||
fail_response = MagicMock()
|
||||
fail_response.status_code = 401
|
||||
fail_response.text = "unauthorized"
|
||||
fail_exc = httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
|
||||
success_resp.raise_for_status = MagicMock(return_value=None)
|
||||
|
||||
post_responses = [fail_exc, success_resp]
|
||||
|
||||
async def fake_post(*args, **kwargs):
|
||||
item = post_responses.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
|
||||
|
||||
call_count = {"refresh": 0, "post": 0}
|
||||
|
||||
async def counting_post(url, **kwargs):
|
||||
if url == _CODEX_REFRESH_TOKEN_URL:
|
||||
call_count["refresh"] += 1
|
||||
return refresh_resp
|
||||
call_count["post"] += 1
|
||||
# First backend call fails with 401 wrapped in an HTTPStatusError-style response,
|
||||
# second succeeds.
|
||||
if call_count["post"] == 1:
|
||||
raise httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
|
||||
return success_resp
|
||||
|
||||
with (
|
||||
patch.object(llm._client, "post", new=counting_post),
|
||||
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
|
||||
):
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
max_retries=0,
|
||||
initial_backoff=0.0,
|
||||
max_backoff=0.0,
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert call_count["refresh"] == 1
|
||||
assert call_count["post"] == 2 # one 401, one success after refresh
|
||||
assert llm.access_token == new_access
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_proactively_refreshes_when_token_is_stale(tmp_path: Path):
|
||||
"""A near-expiry token triggers refresh BEFORE the request is sent."""
|
||||
expired = _make_jwt(int(time.time()) - 60)
|
||||
llm = _build_llm(refresh_token="rt", access_token=expired)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": expired, "refresh_token": "rt"}}))
|
||||
|
||||
new_access = _make_jwt(int(time.time()) + 3600)
|
||||
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
|
||||
|
||||
success_resp = MagicMock()
|
||||
success_resp.status_code = 200
|
||||
success_resp.raise_for_status.return_value = None
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
async def fake_post(url, **kwargs):
|
||||
if url == _CODEX_REFRESH_TOKEN_URL:
|
||||
call_order.append("refresh")
|
||||
return refresh_resp
|
||||
call_order.append("backend")
|
||||
# Assert that by the time the backend is called, the new token is in use.
|
||||
assert kwargs["headers"]["Authorization"] == f"Bearer {new_access}"
|
||||
return success_resp
|
||||
|
||||
with (
|
||||
patch.object(llm._client, "post", new=fake_post),
|
||||
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
|
||||
):
|
||||
await llm.call(
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
max_retries=0,
|
||||
initial_backoff=0.0,
|
||||
max_backoff=0.0,
|
||||
)
|
||||
|
||||
assert call_order == ["refresh", "backend"], "expected proactive refresh BEFORE the backend call"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_does_not_refresh_when_token_is_fresh(tmp_path: Path):
|
||||
fresh = _make_jwt(int(time.time()) + 3600)
|
||||
llm = _build_llm(refresh_token="rt", access_token=fresh)
|
||||
llm._auth_file = tmp_path / "auth.json"
|
||||
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
|
||||
|
||||
success_resp = MagicMock()
|
||||
success_resp.status_code = 200
|
||||
success_resp.raise_for_status.return_value = None
|
||||
|
||||
call_count = {"refresh": 0, "backend": 0}
|
||||
|
||||
async def fake_post(url, **kwargs):
|
||||
if url == _CODEX_REFRESH_TOKEN_URL:
|
||||
call_count["refresh"] += 1
|
||||
raise AssertionError("refresh endpoint should not be hit for a fresh token")
|
||||
call_count["backend"] += 1
|
||||
return success_resp
|
||||
|
||||
with (
|
||||
patch.object(llm._client, "post", new=fake_post),
|
||||
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
|
||||
):
|
||||
await llm.call(
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
max_retries=0,
|
||||
initial_backoff=0.0,
|
||||
max_backoff=0.0,
|
||||
)
|
||||
|
||||
assert call_count == {"refresh": 0, "backend": 1}
|
||||
@@ -112,6 +112,35 @@ def _ensure_embedding_dimension_with_retry(db_url: str, dimension: int, schema:
|
||||
raise
|
||||
|
||||
|
||||
def _assert_raises_runtime_error_with_retry(
|
||||
db_url: str,
|
||||
dimension: int,
|
||||
schema: str,
|
||||
expected_messages: list[str],
|
||||
):
|
||||
"""Assert ensure_embedding_dimension raises RuntimeError, retrying on transient OID errors.
|
||||
|
||||
Concurrent xdist workers can cause 'could not open relation with OID' errors
|
||||
that mask the expected RuntimeError. This retries to give the system a chance to
|
||||
reach the actual dimension-mismatch check.
|
||||
"""
|
||||
import time
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
ensure_embedding_dimension(db_url, dimension, schema=schema)
|
||||
raise AssertionError("Expected RuntimeError but ensure_embedding_dimension succeeded")
|
||||
except RuntimeError as e:
|
||||
for msg in expected_messages:
|
||||
assert msg in str(e), f"Expected '{msg}' in error message, got: {e}"
|
||||
return
|
||||
except Exception as e:
|
||||
if "could not open relation with OID" in str(e) and attempt < 2:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def get_column_dimension(db_url: str, schema: str = "public", table: str = "memory_units") -> int | None:
|
||||
"""Get the current embedding column dimension from the database."""
|
||||
engine = create_engine(db_url)
|
||||
@@ -255,12 +284,12 @@ class TestEmbeddingDimension:
|
||||
insert_test_embedding(db_url, schema, 384)
|
||||
assert get_row_count(db_url, schema) == 1
|
||||
|
||||
# Try to change dimension - should raise error
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
ensure_embedding_dimension(db_url, 768, schema=schema)
|
||||
|
||||
assert "Cannot change embedding dimension" in str(exc_info.value)
|
||||
assert "1 rows with embeddings" in str(exc_info.value)
|
||||
# Try to change dimension - should raise RuntimeError.
|
||||
# Retry on transient OID errors from concurrent xdist schema drops.
|
||||
_assert_raises_runtime_error_with_retry(
|
||||
db_url, 768, schema,
|
||||
expected_messages=["Cannot change embedding dimension", "1 rows with embeddings"],
|
||||
)
|
||||
|
||||
# Dimension should be unchanged
|
||||
assert get_column_dimension(db_url, schema) == 384
|
||||
@@ -300,11 +329,12 @@ class TestEmbeddingDimension:
|
||||
clear_mental_model_embeddings(db_url, schema)
|
||||
insert_test_mental_model_embedding(db_url, schema, 384)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
ensure_embedding_dimension(db_url, 768, schema=schema)
|
||||
|
||||
assert "Cannot change embedding dimension" in str(exc_info.value)
|
||||
assert "mental_models" in str(exc_info.value)
|
||||
# Try to change dimension - should raise RuntimeError.
|
||||
# Retry on transient OID errors from concurrent xdist schema drops.
|
||||
_assert_raises_runtime_error_with_retry(
|
||||
db_url, 768, schema,
|
||||
expected_messages=["Cannot change embedding dimension", "mental_models"],
|
||||
)
|
||||
|
||||
assert get_column_dimension(db_url, schema, table="mental_models") == 384
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from hindsight_api.api.http import MentalModelTrigger
|
||||
from hindsight_api.engine.search.tags import TagGroupOr
|
||||
|
||||
|
||||
def test_mental_model_trigger_model_dump_preserves_or_tag_group():
|
||||
trigger = MentalModelTrigger.model_validate(
|
||||
{
|
||||
"tag_groups": [
|
||||
{
|
||||
"or": [
|
||||
{"tags": ["ns:a"], "match": "all_strict"},
|
||||
{"tags": ["ns:b"], "match": "all_strict"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
dumped = trigger.model_dump()
|
||||
|
||||
assert dumped["tag_groups"] == [
|
||||
{
|
||||
"or": [
|
||||
{"tags": ["ns:a"], "match": "all_strict"},
|
||||
{"tags": ["ns:b"], "match": "all_strict"},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_mental_model_trigger_or_tag_group_survives_storage_round_trip():
|
||||
trigger = MentalModelTrigger.model_validate(
|
||||
{
|
||||
"tag_groups": [
|
||||
{
|
||||
"or": [
|
||||
{"tags": ["ns:a"], "match": "all_strict"},
|
||||
{"tags": ["ns:b"], "match": "all_strict"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
round_tripped = MentalModelTrigger.model_validate(trigger.model_dump())
|
||||
|
||||
assert isinstance(round_tripped.tag_groups[0], TagGroupOr)
|
||||
assert round_tripped.model_dump()["tag_groups"] == trigger.model_dump()["tag_groups"]
|
||||
@@ -1,5 +1,5 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
@@ -52,6 +52,24 @@ async def test_json_object_call_adds_json_hint_to_user_message():
|
||||
assert sent_messages[0]["content"].startswith("Return valid json only.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_object_call_strips_gemma_thought_tags_before_parsing():
|
||||
llm = _llm()
|
||||
create = AsyncMock(
|
||||
return_value=_response(content='<thought>\nI should return a compact JSON object.\n</thought>\n{"ok": true}')
|
||||
)
|
||||
llm._client.chat.completions.create = create
|
||||
|
||||
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "Return whether this worked."}],
|
||||
response_format=SimpleJsonResponse,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_payload_with_no_choices_raises_clear_provider_error_without_retry():
|
||||
llm = _llm()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for the opencode-go OpenAI-compatible LLM provider."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_opencode_go_config_has_expected_default_model(monkeypatch):
|
||||
"""HindsightConfig should default opencode-go to the DeepSeek v4 flash model."""
|
||||
from hindsight_api.config import PROVIDER_DEFAULT_MODELS, HindsightConfig, clear_config_cache
|
||||
|
||||
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
|
||||
clear_config_cache()
|
||||
|
||||
try:
|
||||
assert PROVIDER_DEFAULT_MODELS["opencode-go"] == "deepseek-v4-flash"
|
||||
config = HindsightConfig.from_env()
|
||||
assert config.llm_provider == "opencode-go"
|
||||
assert config.llm_model == "deepseek-v4-flash"
|
||||
finally:
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
def test_opencode_go_llm_provider_from_env_has_expected_default_model(monkeypatch):
|
||||
"""LLMProvider.from_env should use the opencode-go provider default model."""
|
||||
from hindsight_api.config import clear_config_cache
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
|
||||
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
|
||||
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "test-key")
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_BASE_URL", raising=False)
|
||||
clear_config_cache()
|
||||
|
||||
try:
|
||||
llm = LLMProvider.from_env()
|
||||
assert llm.provider == "opencode-go"
|
||||
assert llm.model == "deepseek-v4-flash"
|
||||
assert llm.base_url == "https://opencode.ai/zen/go/v1"
|
||||
finally:
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
def test_opencode_go_requires_api_key_like_zai():
|
||||
"""opencode-go is a cloud provider and should require an API key."""
|
||||
from hindsight_api.engine.llm_wrapper import requires_api_key
|
||||
|
||||
assert requires_api_key("opencode-go") is True
|
||||
|
||||
|
||||
def test_opencode_go_uses_openai_compatible_provider_with_default_base_url():
|
||||
"""The provider factory should route opencode-go to OpenAICompatibleLLM."""
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
llm = LLMProvider(
|
||||
provider="opencode-go",
|
||||
api_key="test-key",
|
||||
base_url="",
|
||||
model="deepseek-v4-flash",
|
||||
)
|
||||
|
||||
assert llm.provider == "opencode-go"
|
||||
assert llm.model == "deepseek-v4-flash"
|
||||
assert llm.base_url == "https://opencode.ai/zen/go/v1"
|
||||
assert not llm.base_url.endswith("/")
|
||||
assert isinstance(llm._provider_impl, OpenAICompatibleLLM)
|
||||
assert llm._provider_impl.base_url == "https://opencode.ai/zen/go/v1"
|
||||
|
||||
|
||||
def test_opencode_go_rejects_missing_api_key():
|
||||
"""opencode-go should fail fast without an API key, matching zai behavior."""
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
|
||||
with pytest.raises(ValueError, match="API key is required for opencode-go"):
|
||||
LLMProvider(
|
||||
provider="opencode-go",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="deepseek-v4-flash",
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _drop_reflect_modules() -> None:
|
||||
for name in list(sys.modules):
|
||||
if name == "hindsight_api.engine.reflect" or name.startswith("hindsight_api.engine.reflect."):
|
||||
sys.modules.pop(name)
|
||||
|
||||
|
||||
def test_reflect_import_does_not_load_tiktoken_encoding():
|
||||
_drop_reflect_modules()
|
||||
|
||||
with patch("tiktoken.get_encoding") as get_encoding:
|
||||
reflect = importlib.import_module("hindsight_api.engine.reflect")
|
||||
|
||||
get_encoding.assert_not_called()
|
||||
assert reflect.run_reflect_agent is not None
|
||||
|
||||
|
||||
def test_reflect_token_counting_loads_tiktoken_encoding_when_used():
|
||||
_drop_reflect_modules()
|
||||
fake_encoding = MagicMock()
|
||||
fake_encoding.encode.side_effect = lambda text: text.split()
|
||||
|
||||
with patch("tiktoken.get_encoding", return_value=fake_encoding) as get_encoding:
|
||||
agent = importlib.import_module("hindsight_api.engine.reflect.agent")
|
||||
prompts = importlib.import_module("hindsight_api.engine.reflect.prompts")
|
||||
|
||||
count = agent._count_messages_tokens([{"role": "user", "content": "one two"}])
|
||||
final_prompt = prompts.build_final_prompt(
|
||||
query="What happened?",
|
||||
context_history=[{"tool": "recall", "output": {"answer": "three four"}}],
|
||||
bank_profile={"name": "test"},
|
||||
max_context_tokens=1000,
|
||||
)
|
||||
|
||||
assert count == 2
|
||||
assert "three four" in final_prompt
|
||||
get_encoding.assert_called_once_with("cl100k_base")
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"hindsight-api-slim[all]==0.6.1",
|
||||
"hindsight-api-slim[all]==0.6.2",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -1262,8 +1262,8 @@ impl ApiClient {
|
||||
|
||||
// Re-export types from the generated client for use in commands
|
||||
pub use types::{
|
||||
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
|
||||
ReflectResponse, RetainRequest,
|
||||
BankProfileResponse, MemoryItem, MemoryItemTimestamp, RecallRequest, RecallResponse,
|
||||
RecallResult, ReflectRequest, ReflectResponse, RetainRequest,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
|
||||
use crate::api::{
|
||||
ApiClient, MemoryItem, MemoryItemTimestamp, RecallRequest, ReflectRequest, RetainRequest,
|
||||
};
|
||||
use crate::config;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
@@ -438,6 +440,7 @@ pub fn retain(
|
||||
content: String,
|
||||
doc_id: Option<String>,
|
||||
context: Option<String>,
|
||||
timestamp: Option<String>,
|
||||
r#async: bool,
|
||||
document_tags: Option<Vec<String>>,
|
||||
verbose: bool,
|
||||
@@ -451,11 +454,20 @@ pub fn retain(
|
||||
None
|
||||
};
|
||||
|
||||
// MemoryItem.timestamp is a progenitor anyOf enum; round-trip through JSON to pick the matching variant.
|
||||
let timestamp = match timestamp {
|
||||
Some(s) => Some(
|
||||
serde_json::from_value::<MemoryItemTimestamp>(serde_json::Value::String(s.clone()))
|
||||
.with_context(|| format!("invalid --timestamp value: {:?}", s))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let item = MemoryItem {
|
||||
content: content.clone(),
|
||||
context,
|
||||
metadata: None,
|
||||
timestamp: None,
|
||||
timestamp,
|
||||
document_id: Some(doc_id.clone()),
|
||||
entities: None,
|
||||
tags: None,
|
||||
|
||||
@@ -591,6 +591,12 @@ enum MemoryCommands {
|
||||
#[arg(short = 'c', long)]
|
||||
context: Option<String>,
|
||||
|
||||
/// When the content occurred (ISO 8601 datetime, e.g. 2024-01-15T10:30:00Z
|
||||
/// or 2024-01-15). Pass "unset" to store without a timestamp.
|
||||
/// Omit to default to now.
|
||||
#[arg(short = 't', long)]
|
||||
timestamp: Option<String>,
|
||||
|
||||
/// Queue for background processing
|
||||
#[arg(long)]
|
||||
r#async: bool,
|
||||
@@ -1434,6 +1440,7 @@ fn run() -> Result<()> {
|
||||
content,
|
||||
doc_id,
|
||||
context,
|
||||
timestamp,
|
||||
r#async,
|
||||
document_tags,
|
||||
} => commands::memory::retain(
|
||||
@@ -1442,6 +1449,7 @@ fn run() -> Result<()> {
|
||||
content,
|
||||
doc_id,
|
||||
context,
|
||||
timestamp,
|
||||
r#async,
|
||||
document_tags,
|
||||
verbose,
|
||||
|
||||
@@ -91,6 +91,25 @@ fn test_ui_command_with_config() {
|
||||
std::fs::remove_dir_all(&temp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_retain_exposes_timestamp_flag() {
|
||||
// Regression: `hindsight memory retain` historically had no way to set the
|
||||
// memory's event date even though the SDKs do. The flag must appear in
|
||||
// --help so users (and docs) can discover it.
|
||||
let output = Command::new("cargo")
|
||||
.args(["run", "--", "memory", "retain", "--help"])
|
||||
.output()
|
||||
.expect("Failed to execute command");
|
||||
|
||||
assert!(output.status.success(), "retain --help failed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("--timestamp") && stdout.contains("-t"),
|
||||
"expected --timestamp/-t flag in retain --help, got: {}",
|
||||
stdout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_command() {
|
||||
// Test that configure command creates/updates config
|
||||
|
||||
@@ -7,7 +7,7 @@ info:
|
||||
name: Apache 2.0
|
||||
url: https://www.apache.org/licenses/LICENSE-2.0.html
|
||||
title: Hindsight HTTP API
|
||||
version: 0.6.1
|
||||
version: 0.6.2
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -41,7 +41,7 @@ var (
|
||||
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
|
||||
)
|
||||
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.6.1
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.6.2
|
||||
// In most cases there should be only one, shared, APIClient.
|
||||
type APIClient struct {
|
||||
cfg *Configuration
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.1
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user