Compare commits

...
Author SHA1 Message Date
Ben f44a6a2703 Merge branch 'main' into docs-codex-cloud-first 2026-05-22 09:23:09 -04:00
Ben f559ae1649 docs(smolagents): prioritize Hindsight Cloud in quickstart (#1692)
Lead README/docs/guide Quick Start with Cloud sign-up + Cloud API
URL example; demote self-hosted localhost:8888 to a 'Self-hosting
(local development)' section below. Update docstring example.
2026-05-22 09:17:39 -04:00
Ben 8ed9a4ebb2 docs(pydantic-ai): prioritize Hindsight Cloud in quickstart (#1691)
Lead README/docs/guide Quick Start with Cloud sign-up + Cloud
base_url example; demote self-hosted localhost:8888 to a
'Self-hosting (local development)' section below. Update docstring
example in __init__.py.
2026-05-22 09:17:07 -04:00
Ben 722aa902fb Regenerate hindsight-docs skill references (#1686)
Adds opencode-go to the integration lists in the generated skill
references. Picked up by the generate-docs-skill.sh pre-commit hook
as drift from the hindsight-docs sources on main.
2026-05-22 09:16:27 -04:00
Ben 5c7e783e18 docs(pipecat): prioritize Hindsight Cloud in quickstart (#1695)
Lead README/docs/guide Quick Start with Cloud, demote self-hosted to
its own section. Updates configure() global example to Cloud default.
2026-05-22 09:15:48 -04:00
Ben e779f10fc7 docs(strands): prioritize Hindsight Cloud in quickstart (#1690)
Lead README/docs/guide Quick Start with Hindsight Cloud sign-up and
Cloud API URL example; demote self-hosted localhost:8888 to a
'Self-hosting (local development)' section below. Update docstring
example in __init__.py to show Cloud-first usage.

Includes 2-line incidental skills/hindsight-docs/ regeneration drift.
2026-05-22 09:15:24 -04:00
Ben 1b2c9f63aa docs(crewai): prioritize Hindsight Cloud in quickstart (#1689)
- Lead README/docs/guide Quick Start with Hindsight Cloud sign-up
  and the Cloud API URL example; demote self-hosted localhost:8888
  to a "Self-hosting (local development)" section below.
- Fix unconfigured-fallback inconsistency in HindsightStorage and
  HindsightReflectTool: previously fell back to localhost:8888
  even though the documented default is Cloud. Now both fallbacks
  use DEFAULT_HINDSIGHT_API_URL.
- Update docstring examples in __init__.py and storage.py to reflect
  the Cloud-first default.
- Update fallback assertion in tests/test_storage.py.
2026-05-22 09:14:56 -04:00
Ben 7ffe6a104b style: apply ruff format to openai_compatible_llm.py (#1703) 2026-05-21 14:36:17 -04:00
Ben d0a6dcf770 docs(codex): prioritize Hindsight Cloud over local daemon
Add Cloud Recommended callouts to README + docs + guide. Reframe the
'Local Daemon' section as the self-hosting alternative rather than a
peer option. No code default changes — codex still defaults to empty
hindsightApiUrl (local daemon) to avoid breaking existing local users.

Includes 2-line incidental skills/hindsight-docs/ regen drift.
2026-05-21 13:26:59 -04:00
Ben 113d7da987 Blog: Agent Memory Consolidation framework (#1672)
* Add blog post: Agent Memory Consolidation framework
2026-05-21 10:42:41 -04:00
Chandler bd86e7ead0 fix(typescript-client): update repository URL to correct repo (#1657) 2026-05-19 16:51:46 -04:00
Ben 795c081d9f fix(api): auto-refresh openai-codex OAuth access_token (#1637) (#1661)
The openai-codex provider was a startup-only credential loader: it read
~/.codex/auth.json once at __init__ and used the cached access_token
forever. ChatGPT OAuth tokens are short-lived (hours), so any
long-running deployment 401d on every request once the cached token
expired. The only recovery was an external cron + container restart.

This change makes the provider refresh tokens itself, mirroring the
canonical @openai/codex CLI (codex-rs/login/src/auth/manager.rs):

- Loads tokens.refresh_token from auth.json (previously discarded).
- Proactive refresh: decodes the access_token JWT's exp claim and
  refreshes ~60s before expiry. Cheap when the token is fresh.
- Reactive refresh: on a 401/403 from the codex backend, refreshes
  once and retries the request without consuming a normal-retry budget
  slot.
- Single-flight: serializes through asyncio.Lock so concurrent callers
  produce one network refresh, not N. Re-checks under the lock by
  comparing the cached token before/after wait to handle the case
  where another coroutine rotated mid-wait.
- Atomic persistence: writes auth.json via tempfile + os.replace with
  mode 0600. The upstream Rust CLI uses truncate-and-overwrite, which
  a concurrent reader can catch mid-write; tempfile+rename is strictly
  safer.
- Terminal error handling: refresh_token_expired/reused/invalidated
  (and any 401 from the refresh endpoint) raise CodexRefreshExpiredError
  with a clear "run codex auth login" remediation, and do not loop.
- No secrets in logs: refresh logs the reason and outcome but not the
  token values themselves.

OAuth request shape (POST https://auth.openai.com/oauth/token, JSON
body with hardcoded client_id app_EMoamEEZ73f0CkXaXp7hrann,
grant_type=refresh_token) matches the upstream Rust CLI exactly. The
endpoint is overridable via the CODEX_REFRESH_TOKEN_URL_OVERRIDE env
var the same way the upstream CLI supports it.

Tests: 23 new in test_codex_oauth_refresh.py covering JWT exp decode,
staleness with skew, refresh_token loading, atomic persistence with
0600 mode, request shape, in-memory + on-disk update, refresh_token
rotation, terminal-error classification, network error wrapping,
no-secrets-in-logs, single-flight under 10 concurrent callers,
proactive refresh before request, reactive 401-then-retry, and the
no-refresh-when-fresh case. Existing test_codex_tool_choice.py still
passes.

Caveat: all tests are mocked. The OAuth request shape has not been
verified against the real auth.openai.com endpoint - it is grounded
in the upstream codex-rs source on github.com/openai/codex.
Reviewers with a ChatGPT Plus subscription should validate the
end-to-end path before merge.
2026-05-19 16:49:03 -04:00
Minghao Xiao 9c161e4e59 fix(api): preserve tag group or triggers (#1655) 2026-05-19 16:35:01 -04:00
Minghao Xiao 9643e66e77 fix(api): lazy load reflect tiktoken encoding (#1654) 2026-05-19 16:34:24 -04:00
Teven Feng c29c76e3fa feat: add opencode-go LLM provider (#1652) 2026-05-19 16:34:11 -04:00
Minghao Xiao c16d9978e8 fix(api): strip Gemma thought tags (#1653) 2026-05-19 16:33:59 -04:00
Ben 943dfee624 docs: add HINDSIGHT_API_WORKER_ID tip to API quickstart (#1617)
* docs: add HINDSIGHT_API_WORKER_ID tip to API quickstart

Mirrors the tip already present in installation.md so users who follow
the API quickstart's Docker tab see the same guidance about pinning a
stable worker ID. Closes #1616.

* docs: mirror WORKER_ID tip to versioned_docs v0.6 (from #1648)

Folding in xmh1011's strict-improvement hunk from #1648: the
versioned snapshot for v0.6 should carry the same production tip
as the live doc. Same prose, same `:::tip` block. Includes the
auto-regenerated skills/ reference.
2026-05-19 16:28:17 -04:00
Ben ab0caa658e docs(changelog): correct openai-agents v0.1.1 entry (#1639)
Replaces the auto-generated entry, which credited #1123 (a core-engine
consolidation config, not openai-agents-specific) to the v0.1.1 release.
The actual openai-agents-specific work in v0.1.1 was #1134 by @DK09876:
docs/test polish — corrected SDK version requirement, added
memory_instructions() to README and API reference, added Production
Patterns section, and added test_config.py.
2026-05-19 16:27:56 -04:00
Chandler 2cb65e09e3 feat(typescript-client): replace Promise<any> with concrete generated types (#1640) 2026-05-19 16:27:14 -04:00
Ben d1903f3c9f blog: What's New in Hindsight Cloud (#1636)
* blog: What's New in Hindsight Cloud — Going Global
2026-05-19 10:50:42 -04:00
Ben 9784f6573a release(openclaw): v0.7.7 2026-05-15 15:11:52 -04:00
Ben f02e037bc7 release(openai-agents): v0.1.1 2026-05-15 14:50:39 -04:00
Ben 87734b3a44 release(litellm): v0.5.3 2026-05-15 14:46:54 -04:00
Ben f613f005a6 release(strands): v0.1.3 2026-05-15 14:22:37 -04:00
Ben 6a5e2d1800 release(claude-code): v0.6.5 2026-05-15 14:21:30 -04:00
Ben 6d495290bc release(paperclip): v0.2.2 2026-05-15 14:12:33 -04:00
Ben f94e840fe8 docs: attribute 0.6.2 release post to benfrank241 (#1635) 2026-05-14 17:07:12 -04:00
Ben 25052a56d0 docs: add 0.6.2 changelog and release blog post (#1633)
Documents the security/maintenance release: dependency CVE bumps,
mental_models.subtype migration repair, embedding-dimension OID
handling, and integration fixes for Claude Code, Agent SDK, CLI,
and Paperclip.
2026-05-14 16:55:48 -04:00
Ben 8b10231b8b Release v0.6.2
- Update version to 0.6.2 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.6
2026-05-14 16:00:16 -04:00
Ben 5a7996a649 blog: onboarding a new engineer onto five months of OpenCode memory (#1628)
* blog: add OpenCode onboarding use-case post
2026-05-14 14:57:37 -04:00
dependabot[bot] 059d1c3e94 chore(deps): bump the uv group across 3 directories with 8 updates (#1630) 2026-05-14 10:57:24 -04:00
Derek Bouius b20c0d8f67 fix(ci): set UV_FROZEN=1 on verify-generated-files job (#1629)
Set UV_FROZEN=1 as a job-level env var so all uv commands (sync, run,
lock) respect the committed lockfile without re-resolving. This is the
idiomatic uv approach for CI and prevents spurious uv.lock diffs that
blocked every Dependabot PR.

Reverts the lint.sh CI-specific --frozen logic from #1618 since the
env var covers it globally.
2026-05-14 10:24:47 -04:00
Ben debbd91961 fix(migrations): repair mental_models.subtype at current head (#1553) (#1627)
Three production deployments (issue #1553, plus confirmations from
@4Lienau and @khanhduyvt0101) report `column "subtype" of relation
"mental_models" does not exist` on `create_mental_model`, despite their
alembic_version showing the current head `m3rg3h3ad5f6`.

Both h3c4d5e6f7g8_mental_models_v4 (which uses `CREATE TABLE IF NOT EXISTS`
and is a no-op on databases that came through the reflections rename) and
d5y6z7a8b9c0_backfill_mental_models_subtype were meant to ensure the
column exists, but on these specific deployments neither fired
successfully — likely a casualty of the divergent-heads reorganization
that put d5y6z7a8b9c0 on a branch the affected DBs bypassed.

Add a new migration at the current head so every stuck deployment picks
it up on next container start. Idempotent (`ADD COLUMN IF NOT EXISTS`),
guarded by an existence check on the table, and matches the canonical v4
column set and CHECK allowlist from d5y6z7a8b9c0.

PG-only: Oracle's baseline creates mental_models with a different
topology and constraint shape, so this repair does not apply there.
2026-05-14 10:14:48 -04:00
Evo ed35894f55 docs(cli): document --timestamp flag on memory retain (#1622) (#1623)
* docs(cli): document new --timestamp flag on memory retain (#1622)

* docs(cli): mirror --timestamp flag in skills CLI reference
2026-05-14 09:32:09 -04:00
Evo 190c31f543 docs(claude-code): document requestTimeoutSeconds option from #1591 (#1626) 2026-05-14 09:31:11 -04:00
Chris Latimer 5a2c138779 Updated benchmark scores 2026-05-14 05:30:57 -06:00
Ben 51ea9aa286 fix(cli, control-plane): make retain Event Date / timestamp actually reach the API (#1622)
* fix(cli, control-plane): make Event Date / timestamp actually reach the API

- CLI `hindsight memory retain` now accepts `-t/--timestamp <ISO>`. The
  internal MemoryItem.timestamp was hardcoded to None, so retains from the
  CLI lost any caller-supplied event date even though the Python/Node/Go
  SDKs accept one. Add a flag and pass it through; regression test asserts
  --help advertises the option.
- Control plane "Event Date" inputs in the new-document and per-file flows
  used `<input type="datetime-local">`, which only commits a value when the
  user enters both date AND time. Typing a date alone silently left the
  value empty, so `item.timestamp` was never sent and the resulting
  operation payload had no event_date. Switch to `type="date"` and pad
  with `T00:00:00` before sending, so date-only entries reach the API as
  valid ISO datetimes.

* fix(cli): decode --timestamp into MemoryItemTimestamp enum

MemoryItem.timestamp is generated as Option<MemoryItemTimestamp>
(progenitor's anyOf wrapper), not Option<String>. Round-trip the
flag value through serde_json so the right variant is selected for
both ISO datetimes and the 'unset' sentinel. Fixes CI build break.
2026-05-13 16:51:21 -04:00
Ben f9fbfe55c2 fix(docs): use real GitHub handle for ContextForge integration author (#1621)
The `by` field was set to `omarouldali`, which is not a real GitHub user
(github.com/omarouldali returns 404). As a result the avatar request to
`github.com/omarouldali.png?size=40` failed and the integrations hub card
showed a broken-image placeholder next to the author name. The actual
GitHub handle of the contributor (author of PRs #961 and #1254) is
`ooa-andera`, which resolves cleanly.
2026-05-13 15:50:19 -04:00
Derek Bouius 5c7aea4717 fix(ci): use frozen lockfile in lint.sh during CI (#1618)
lint.sh runs `uv sync` without --frozen at the repo root, which
re-resolves uv.lock. In CI's verify-generated-files job this causes
spurious 1-line diffs on every Dependabot PR, blocking them from
merging.

Use --frozen when $CI is set so the lockfile is never modified by
the lint step. Local development keeps the non-frozen sync to handle
version bumps gracefully.
2026-05-13 13:58:48 -04:00
Derek Bouius 9dfbfb4bd0 fix: handle transient OID errors in embedding dimension migration (#1612)
The DO $$ block that drops vector indexes iterates pg_indexes via a
cursor. When concurrent pytest-xdist workers drop schemas (CASCADE),
the OID references in the cursor become stale, causing
'could not open relation with OID' errors.

Fix the root cause in migrations.py by adding EXCEPTION WHEN
internal_error handling to the PL/pgSQL DO block. Also add
defense-in-depth retry logic to the two test cases that previously
called ensure_embedding_dimension() without the retry wrapper.
2026-05-13 13:05:51 -04:00
373 changed files with 2800 additions and 558 deletions
+2
View File
@@ -3463,6 +3463,8 @@ jobs:
verify-generated-files:
runs-on: ubuntu-latest
env:
UV_FROZEN: "1"
steps:
- uses: actions/checkout@v6
with:
+1 -1
View File
@@ -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:
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
![Overview](./hindsight-docs/static/img/hindsight-benchmarks.png)
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 -2
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
]
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.6.1"
__version__ = "0.6.2"
@@ -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 $$;
""")
)
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -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 -1
View File
@@ -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"
+2 -2
View File
@@ -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)]
+14 -2
View File
@@ -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,
+8
View File
@@ -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,
+19
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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