Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44a6a2703 | ||
|
|
f559ae1649 | ||
|
|
8ed9a4ebb2 | ||
|
|
722aa902fb | ||
|
|
5c7e783e18 | ||
|
|
e779f10fc7 | ||
|
|
1b2c9f63aa | ||
|
|
7ffe6a104b | ||
|
|
d0a6dcf770 | ||
|
|
113d7da987 | ||
|
|
bd86e7ead0 | ||
|
|
795c081d9f | ||
|
|
9c161e4e59 | ||
|
|
9643e66e77 | ||
|
|
c29c76e3fa | ||
|
|
c16d9978e8 | ||
|
|
943dfee624 | ||
|
|
ab0caa658e | ||
|
|
2cb65e09e3 | ||
|
|
d1903f3c9f | ||
|
|
9784f6573a | ||
|
|
f02e037bc7 | ||
|
|
87734b3a44 | ||
|
|
f613f005a6 | ||
|
|
6a5e2d1800 | ||
|
|
6d495290bc | ||
|
|
f94e840fe8 | ||
|
|
25052a56d0 | ||
|
|
8b10231b8b | ||
|
|
5a7996a649 | ||
|
|
059d1c3e94 | ||
|
|
b20c0d8f67 | ||
|
|
debbd91961 | ||
|
|
ed35894f55 | ||
|
|
190c31f543 | ||
|
|
5a2c138779 | ||
|
|
51ea9aa286 | ||
|
|
f9fbfe55c2 | ||
|
|
5c7aea4717 | ||
|
|
9dfbfb4bd0 | ||
|
|
b593d40fdf | ||
|
|
55ef70679c | ||
|
|
fd05bdab51 | ||
|
|
a6cd28a3b5 | ||
|
|
9533107612 | ||
|
|
26c5028c94 | ||
|
|
9b8d8b5632 | ||
|
|
d9dd14995c | ||
|
|
378097ba3d | ||
|
|
69703e5a31 | ||
|
|
771922cd70 | ||
|
|
61730d5924 | ||
|
|
be908d5b2c | ||
|
|
2471f01107 | ||
|
|
2bfd77477a | ||
|
|
6aab6c89dc | ||
|
|
909a4fd400 | ||
|
|
4d486b9262 | ||
|
|
43c4015afa | ||
|
|
b2a693ab7a | ||
|
|
0dcd605751 | ||
|
|
838ddb0f30 | ||
|
|
0f7c5e895b | ||
|
|
98f33cbf6c | ||
|
|
ab8cc3e605 | ||
|
|
da72f5da44 | ||
|
|
48295b06e0 | ||
|
|
3f08b115f8 | ||
|
|
b628716f15 | ||
|
|
be696b0d38 | ||
|
|
4c75cd9e37 | ||
|
|
c0ff87ea10 | ||
|
|
6c6ee73c56 | ||
|
|
7b82d05b77 | ||
|
|
f2a2f9fe40 | ||
|
|
8c6be6de6c | ||
|
|
8d77976ae9 | ||
|
|
312bde1b4d | ||
|
|
a22e8bdd22 | ||
|
|
4088af369a | ||
|
|
be53972ab2 | ||
|
|
691c65acf8 | ||
|
|
bfa3115579 | ||
|
|
ef600683c9 | ||
|
|
a34c0e7be3 | ||
|
|
abf8487248 | ||
|
|
5d867826a1 | ||
|
|
e18ab10ac4 | ||
|
|
39d31ad259 |
+32
-2
@@ -2,7 +2,7 @@
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# LLM Configuration (Required)
|
||||
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, volcano
|
||||
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, volcano
|
||||
HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
|
||||
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
|
||||
@@ -30,6 +30,11 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
# HINDSIGHT_API_LLM_API_KEY=your-deepseek-api-key
|
||||
# HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash # or deepseek-v4-pro / deepseek-chat / deepseek-reasoner
|
||||
|
||||
# Example: z.ai configuration (Zhipu GLM series, https://z.ai)
|
||||
# HINDSIGHT_API_LLM_PROVIDER=zai
|
||||
# HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
|
||||
# HINDSIGHT_API_LLM_MODEL=glm-4.5-flash # or glm-4.5-air for the paid tier
|
||||
|
||||
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
|
||||
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
|
||||
# HINDSIGHT_API_LLM_API_KEY=lmstudio
|
||||
@@ -60,12 +65,25 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure
|
||||
|
||||
# Embeddings Configuration (Optional - uses local by default)
|
||||
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
|
||||
# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "litellm", or "litellm-sdk"
|
||||
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
|
||||
# For local provider:
|
||||
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
|
||||
# Optional for China network / restricted HF access:
|
||||
# HF_ENDPOINT=https://hf-mirror.com
|
||||
# For TEI provider:
|
||||
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
|
||||
# For OpenAI-compatible embeddings:
|
||||
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxx
|
||||
# HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small
|
||||
# HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
#
|
||||
# IMPORTANT: Embedding keys require provider-specific names:
|
||||
# HINDSIGHT_API_EMBEDDINGS_{PROVIDER}_{PARAMETER}
|
||||
# (for example, HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL).
|
||||
#
|
||||
# DeepSeek note: DeepSeek is supported for LLM calls, but not for embeddings.
|
||||
# If using DeepSeek as LLM provider, keep embeddings on local/openai/cohere/google/etc.
|
||||
|
||||
# Reranker Configuration (Optional - uses local by default)
|
||||
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
|
||||
@@ -89,3 +107,15 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# Custom service name and environment (optional, defaults: hindsight-api, development)
|
||||
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
|
||||
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Control Plane (Optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Dataplane API URL - where the CP proxies requests to
|
||||
# HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
|
||||
|
||||
# Optional: Require a shared access key to view the Control Plane UI.
|
||||
# When set, visitors see a login page and must enter the key before
|
||||
# accessing the dashboard or any /api/* routes (except /api/health).
|
||||
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key
|
||||
|
||||
@@ -25,10 +25,10 @@ on:
|
||||
- recall-with-observations
|
||||
- consolidation
|
||||
default: ""
|
||||
locomo_max_conversations:
|
||||
description: "LoComo max conversations (0 = skip, blank = all)"
|
||||
type: number
|
||||
default: 0
|
||||
locomo_conversations:
|
||||
description: "LoComo conversation IDs (space-separated). Blank = curated set (conv-26 conv-30 conv-43)."
|
||||
type: string
|
||||
default: ""
|
||||
locomo_skip:
|
||||
description: "Skip LoComo job"
|
||||
type: boolean
|
||||
@@ -169,14 +169,20 @@ jobs:
|
||||
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
|
||||
|
||||
- name: Run LoComo benchmark
|
||||
# Curated 3-conversation subset (best/middle/worst by accuracy on the
|
||||
# last successful full run): conv-26 (best), conv-30 (middle), conv-43
|
||||
# (worst). Excludes conv-44, the bank with the largest unconsolidated
|
||||
# set that has been pushing scheduled runs over the per-bank
|
||||
# _wait_for_consolidation timeout. Override via workflow_dispatch with
|
||||
# the locomo_conversations input.
|
||||
run: |
|
||||
MAX_CONV_ARG=""
|
||||
if [ "${{ inputs.locomo_max_conversations }}" != "0" ] && [ -n "${{ inputs.locomo_max_conversations }}" ]; then
|
||||
MAX_CONV_ARG="--max-conversations ${{ inputs.locomo_max_conversations }}"
|
||||
CONVERSATIONS="${{ inputs.locomo_conversations }}"
|
||||
if [ -z "$CONVERSATIONS" ]; then
|
||||
CONVERSATIONS="conv-26 conv-30 conv-43"
|
||||
fi
|
||||
uv run python hindsight-dev/benchmarks/locomo/locomo_benchmark.py \
|
||||
--wait-consolidation \
|
||||
$MAX_CONV_ARG
|
||||
--conversation $CONVERSATIONS
|
||||
|
||||
- name: Upload LoComo results
|
||||
if: always()
|
||||
|
||||
@@ -1130,6 +1130,10 @@ jobs:
|
||||
api_key_secret: GROQ_API_KEY
|
||||
- provider: bedrock
|
||||
model: us.amazon.nova-2-lite-v1:0
|
||||
- provider: litellmrouter
|
||||
model: gpt-4.1-nano
|
||||
# Single-deployment chain over OpenAI — verifies the Router-backed
|
||||
# call path works end-to-end. Built from secrets in the step below.
|
||||
name: LLM acceptance (${{ matrix.provider }}/${{ matrix.model }})
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: ${{ matrix.provider }}
|
||||
@@ -1187,6 +1191,19 @@ jobs:
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
||||
"
|
||||
|
||||
- name: Build litellmrouter config
|
||||
if: matrix.provider == 'litellmrouter'
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ROUTER_MODEL: ${{ matrix.model }}
|
||||
run: |
|
||||
cfg=$(jq -nc \
|
||||
--arg model "openai/$ROUTER_MODEL" \
|
||||
--arg key "$OPENAI_API_KEY" \
|
||||
'{model_list: [{model_name: "default", litellm_params: {model: $model, api_key: $key}}]}')
|
||||
echo "::add-mask::$cfg"
|
||||
echo "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG=$cfg" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run LLM acceptance tests
|
||||
working-directory: ./hindsight-api-slim
|
||||
run: uv run pytest tests -v -m "hs_llm_mat" --timeout 600
|
||||
@@ -3446,6 +3463,8 @@ jobs:
|
||||
|
||||
verify-generated-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_FROZEN: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
@@ -30,7 +30,7 @@ It eliminates the shortcomings of alternative techniques such as RAG and knowled
|
||||
|
||||
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
|
||||
|
||||

|
||||

|
||||
|
||||
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ services:
|
||||
|
||||
# Control Plane config
|
||||
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
|
||||
# Optional: Require a shared access key for Control Plane UI access
|
||||
# HINDSIGHT_CP_ACCESS_KEY: your-secret-key
|
||||
volumes:
|
||||
# Persist embedded pg0 database
|
||||
- hindsight_data:/app/data
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.6.0
|
||||
appVersion: "0.6.0"
|
||||
version: 0.6.2
|
||||
appVersion: "0.6.2"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-all",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.2",
|
||||
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all-slim"
|
||||
version = "0.6.0"
|
||||
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.0",
|
||||
"hindsight-api-slim==0.6.2",
|
||||
"hindsight-client>=0.0.7",
|
||||
"hindsight-embed>=0.1.0",
|
||||
]
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.6.0"
|
||||
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.0",
|
||||
"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.0",
|
||||
"hindsight-api-slim[local-llm]==0.6.2",
|
||||
]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.6.2"
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
"""Repair mental_models.subtype on databases stuck at m3rg3h3ad5f6
|
||||
|
||||
Three production deployments reported `column "subtype" of relation
|
||||
"mental_models" does not exist` on `create_mental_model` even after their
|
||||
container reported `Database migrations completed successfully` and
|
||||
`alembic_version` advanced to `m3rg3h3ad5f6` (see issue #1553, #1553#1
|
||||
confirmations from @4Lienau and @khanhduyvt0101).
|
||||
|
||||
Both `h3c4d5e6f7g8_mental_models_v4` and `d5y6z7a8b9c0_backfill_mental_models_subtype`
|
||||
were meant to ensure `subtype` exists, but on databases that came through the
|
||||
`reflections -> mental_models` rename chain *and* whose alembic_version
|
||||
advanced past `d5y6z7a8b9c0` along an alternate path during the divergent-heads
|
||||
reorganization, neither column-add actually fired. The result is a head-tagged
|
||||
database with a v3-shaped `mental_models` table missing six columns:
|
||||
``subtype``, ``description``, ``entity_id``, ``observations``, ``links``,
|
||||
``last_updated``.
|
||||
|
||||
This migration sits at the current head (`m3rg3h3ad5f6`) so every affected
|
||||
deployment will pick it up on next container start. It mirrors the column-add
|
||||
block from `d5y6z7a8b9c0_backfill_mental_models_subtype` using
|
||||
``ADD COLUMN IF NOT EXISTS`` so it is a no-op on databases where the columns
|
||||
are already present.
|
||||
|
||||
Revision ID: 86f7a033d372
|
||||
Revises: m3rg3h3ad5f6
|
||||
Create Date: 2026-05-14
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "86f7a033d372"
|
||||
down_revision: str | Sequence[str] | None = "m3rg3h3ad5f6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
"""Idempotently ensure mental_models has the v4 column set.
|
||||
|
||||
Safe to re-apply on databases that already received the columns via
|
||||
`h3c4d5e6f7g8_mental_models_v4` or `d5y6z7a8b9c0_backfill_mental_models_subtype` —
|
||||
every column-add uses ``IF NOT EXISTS`` and the constraint is recreated
|
||||
from scratch with the canonical v4 allowlist.
|
||||
"""
|
||||
schema = _pg_schema_prefix()
|
||||
bare_schema = schema.strip(".").strip('"') if schema else ""
|
||||
schema_clause = f"AND table_schema = '{bare_schema}'" if bare_schema else ""
|
||||
|
||||
# Wrapped in a DO block so the existence check skips databases that
|
||||
# predate the reflections -> mental_models rename chain (no table to
|
||||
# repair). On those, every ALTER below would error.
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'mental_models'
|
||||
{schema_clause}
|
||||
) THEN
|
||||
-- Add the six v4 columns idempotently.
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS subtype VARCHAR(32) NOT NULL DEFAULT 'structural';
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS entity_id UUID;
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS observations JSONB DEFAULT '{{"observations": []}}'::jsonb;
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS links VARCHAR[];
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS last_updated TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- Recreate the CHECK constraint with the canonical v4 allowlist.
|
||||
-- Existing rows with subtype = 'directive' (possible on databases
|
||||
-- that ran the o0j1k2l3m4n5 directive-only path) are rewritten to
|
||||
-- 'structural' first so the constraint add succeeds.
|
||||
UPDATE {schema}mental_models SET subtype = 'structural' WHERE subtype = 'directive';
|
||||
|
||||
ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype;
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD CONSTRAINT ck_mental_models_subtype
|
||||
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mental_models_subtype
|
||||
ON {schema}mental_models(bank_id, subtype);
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
"""No-op: dropping these columns would corrupt v4 application code."""
|
||||
pass
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PG-only: Oracle's baseline (o1a2b3c4d5e6) creates mental_models with its
|
||||
# own subtype shape (chk_mm_subtype IN ('directive', 'pinned')) and a
|
||||
# different table topology, so this PG-shaped repair does not apply.
|
||||
run_for_dialect(pg=_pg_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
@@ -141,6 +141,14 @@ ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
|
||||
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
|
||||
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
|
||||
|
||||
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
|
||||
# provider. Each entry is a deployment; the Router tries them in declared order and
|
||||
# falls back to the next on transient errors (5xx, rate-limit, timeout).
|
||||
# Provider-scoped naming mirrors other provider-specific flags (e.g. llm_groq_*,
|
||||
# llm_vertexai_*). Note the single token "LITELLMROUTER" — keeping it one word
|
||||
# disambiguates from the embeddings/reranker LITELLM_* settings.
|
||||
ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
|
||||
|
||||
# Defaults for service tiers
|
||||
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
|
||||
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
|
||||
@@ -159,6 +167,7 @@ ENV_RETAIN_LLM_MAX_RETRIES = "HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"
|
||||
ENV_RETAIN_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"
|
||||
ENV_RETAIN_LLM_MAX_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"
|
||||
ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
|
||||
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_RETAIN_LLM_LITELLMROUTER_CONFIG"
|
||||
|
||||
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
|
||||
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
|
||||
@@ -169,6 +178,7 @@ ENV_REFLECT_LLM_MAX_RETRIES = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"
|
||||
ENV_REFLECT_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"
|
||||
ENV_REFLECT_LLM_MAX_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"
|
||||
ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
|
||||
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_REFLECT_LLM_LITELLMROUTER_CONFIG"
|
||||
|
||||
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
|
||||
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
|
||||
@@ -179,6 +189,7 @@ ENV_CONSOLIDATION_LLM_MAX_RETRIES = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES
|
||||
ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF"
|
||||
ENV_CONSOLIDATION_LLM_MAX_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF"
|
||||
ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
|
||||
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG"
|
||||
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
@@ -459,6 +470,8 @@ PROVIDER_DEFAULT_MODELS = {
|
||||
"groq": "openai/gpt-oss-120b",
|
||||
"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",
|
||||
@@ -496,7 +509,7 @@ DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety se
|
||||
|
||||
DEFAULT_EMBEDDINGS_PROVIDER = "local"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings
|
||||
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_EMBEDDINGS_OPENAI_BATCH_SIZE = 100
|
||||
@@ -507,7 +520,7 @@ DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
|
||||
DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
|
||||
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
|
||||
False # Security: disabled by default, required for some models like jina-reranker-v2
|
||||
@@ -808,6 +821,24 @@ def _get_default_model_for_provider(provider: str) -> str:
|
||||
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
|
||||
|
||||
|
||||
def _parse_llm_router_config(env_var: str) -> dict | None:
|
||||
"""
|
||||
Parse a LiteLLM Router configuration from a JSON env var.
|
||||
|
||||
The value is forwarded verbatim to ``litellm.Router(**config)``. We only
|
||||
check that it parses as JSON; LiteLLM Router is authoritative about the
|
||||
shape (``model_list``, ``fallbacks``, ``routing_strategy``, …). See
|
||||
https://docs.litellm.ai/docs/routing.
|
||||
"""
|
||||
raw = os.getenv(env_var, "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid {env_var}: invalid JSON: {e}") from e
|
||||
|
||||
|
||||
def _parse_default_bank_template(raw: str | None) -> dict | None:
|
||||
"""
|
||||
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
|
||||
@@ -864,6 +895,12 @@ class HindsightConfig:
|
||||
dict | None
|
||||
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
|
||||
|
||||
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
|
||||
# List of deployment dicts evaluated in order with fallback on transient errors.
|
||||
# Each entry: {"provider": str, "model": str, "api_key": str | None, "base_url": str | None}.
|
||||
# Treated as a credential field because entries embed api keys.
|
||||
llm_litellmrouter_config: dict | None
|
||||
|
||||
# Vertex AI configuration
|
||||
llm_vertexai_project_id: str | None
|
||||
llm_vertexai_region: str
|
||||
@@ -890,6 +927,7 @@ class HindsightConfig:
|
||||
retain_llm_initial_backoff: float | None
|
||||
retain_llm_max_backoff: float | None
|
||||
retain_llm_timeout: float | None
|
||||
retain_llm_litellmrouter_config: dict | None
|
||||
|
||||
reflect_llm_provider: str | None
|
||||
reflect_llm_api_key: str | None
|
||||
@@ -900,6 +938,7 @@ class HindsightConfig:
|
||||
reflect_llm_initial_backoff: float | None
|
||||
reflect_llm_max_backoff: float | None
|
||||
reflect_llm_timeout: float | None
|
||||
reflect_llm_litellmrouter_config: dict | None
|
||||
|
||||
consolidation_llm_provider: str | None
|
||||
consolidation_llm_api_key: str | None
|
||||
@@ -910,6 +949,7 @@ class HindsightConfig:
|
||||
consolidation_llm_initial_backoff: float | None
|
||||
consolidation_llm_max_backoff: float | None
|
||||
consolidation_llm_timeout: float | None
|
||||
consolidation_llm_litellmrouter_config: dict | None
|
||||
|
||||
# Embeddings
|
||||
embeddings_provider: str
|
||||
@@ -1149,6 +1189,11 @@ class HindsightConfig:
|
||||
"retain_llm_api_key",
|
||||
"reflect_llm_api_key",
|
||||
"consolidation_llm_api_key",
|
||||
# LiteLLM Router chains — entries embed api_keys and base_urls
|
||||
"llm_litellmrouter_config",
|
||||
"retain_llm_litellmrouter_config",
|
||||
"reflect_llm_litellmrouter_config",
|
||||
"consolidation_llm_litellmrouter_config",
|
||||
# Base URLs (could expose infrastructure)
|
||||
"llm_base_url",
|
||||
"retain_llm_base_url",
|
||||
@@ -1385,6 +1430,7 @@ class HindsightConfig:
|
||||
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
|
||||
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
|
||||
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
|
||||
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
|
||||
# Vertex AI
|
||||
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
|
||||
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
|
||||
@@ -1423,6 +1469,7 @@ class HindsightConfig:
|
||||
if os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF)
|
||||
else None,
|
||||
retain_llm_timeout=float(os.getenv(ENV_RETAIN_LLM_TIMEOUT)) if os.getenv(ENV_RETAIN_LLM_TIMEOUT) else None,
|
||||
retain_llm_litellmrouter_config=_parse_llm_router_config(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG),
|
||||
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
|
||||
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
|
||||
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL)
|
||||
@@ -1447,6 +1494,7 @@ class HindsightConfig:
|
||||
reflect_llm_timeout=float(os.getenv(ENV_REFLECT_LLM_TIMEOUT))
|
||||
if os.getenv(ENV_REFLECT_LLM_TIMEOUT)
|
||||
else None,
|
||||
reflect_llm_litellmrouter_config=_parse_llm_router_config(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG),
|
||||
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
|
||||
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
|
||||
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL)
|
||||
@@ -1471,6 +1519,7 @@ class HindsightConfig:
|
||||
consolidation_llm_timeout=float(os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT))
|
||||
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
|
||||
else None,
|
||||
consolidation_llm_litellmrouter_config=_parse_llm_router_config(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG),
|
||||
# Embeddings
|
||||
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
|
||||
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
|
||||
|
||||
@@ -4,12 +4,20 @@ Daemon mode support for Hindsight API.
|
||||
Provides idle timeout for running as a background daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import IO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +28,12 @@ DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own tim
|
||||
# Allow override via environment variable for profile-specific logs
|
||||
DAEMON_LOG_PATH = Path(os.getenv("HINDSIGHT_API_DAEMON_LOG", str(Path.home() / ".hindsight" / "daemon.log")))
|
||||
|
||||
# Internal env var: set by daemonize() in the re-exec'd child so the child
|
||||
# skips re-exec and just redirects stdio. Also set by hindsight-embed's
|
||||
# DaemonEmbedManager so the daemon launched via Popen skips re-exec entirely
|
||||
# (hindsight-embed's Popen already provides a clean, detached process).
|
||||
ENV_DAEMON_CHILD = "_HINDSIGHT_DAEMON_CHILD"
|
||||
|
||||
|
||||
class IdleTimeoutMiddleware:
|
||||
"""ASGI middleware that tracks activity and exits after idle timeout."""
|
||||
@@ -58,57 +72,105 @@ class IdleTimeoutMiddleware:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
def daemonize():
|
||||
def _detach_popen_kwargs(log_handle: "IO[bytes]") -> dict:
|
||||
"""Cross-platform kwargs to spawn a subprocess detached from the caller.
|
||||
|
||||
On POSIX, ``start_new_session=True`` calls ``setsid(2)`` so the child
|
||||
survives the parent's terminal. On Windows we use
|
||||
``DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP``.
|
||||
|
||||
``log_handle`` receives the child's stdout/stderr so output never leaks
|
||||
into the parent's terminal.
|
||||
"""
|
||||
Fork the current process into a background daemon.
|
||||
if platform.system() == "Windows":
|
||||
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
create_new_process_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
return {
|
||||
"creationflags": detached_process | create_new_process_group,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": log_handle,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"close_fds": True,
|
||||
}
|
||||
return {
|
||||
"start_new_session": True,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": log_handle,
|
||||
"stderr": log_handle,
|
||||
}
|
||||
|
||||
Uses double-fork technique to properly detach from terminal.
|
||||
|
||||
On Windows there is no fork model: the spawning parent is expected to
|
||||
detach us via `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS` and to
|
||||
redirect stdout/stderr to HINDSIGHT_API_DAEMON_LOG before exec. We
|
||||
still ensure the log directory exists so that any file handlers set
|
||||
up by the calling app have a valid target.
|
||||
def _redirect_stdio_to_log() -> None:
|
||||
"""Redirect stdin/stdout/stderr to the daemon log file.
|
||||
|
||||
Called in the daemon child process after re-exec.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
|
||||
# First fork - detach from parent
|
||||
try:
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"fork #1 failed: {e}\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Decouple from parent environment
|
||||
os.chdir("/")
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# Second fork - prevent zombie
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
# Redirect standard file descriptors to log file
|
||||
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
|
||||
# Redirect stdin to /dev/null
|
||||
with open("/dev/null", "r") as devnull:
|
||||
with open(os.devnull, "r") as devnull:
|
||||
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
||||
|
||||
# Redirect stdout/stderr to log file
|
||||
log_fd = open(DAEMON_LOG_PATH, "a")
|
||||
os.dup2(log_fd.fileno(), sys.stdout.fileno())
|
||||
os.dup2(log_fd.fileno(), sys.stderr.fileno())
|
||||
|
||||
|
||||
def daemonize():
|
||||
"""Detach the current process into a background daemon.
|
||||
|
||||
Uses ``subprocess.Popen`` (which maps to ``posix_spawn`` on macOS) to
|
||||
re-exec the current command in a detached session. This replaces the
|
||||
traditional double-fork pattern because ``os.fork()`` without ``exec()``
|
||||
corrupts Apple framework state (XPC, Metal/MPS, ObjC runtime) on macOS,
|
||||
causing SIGBUS crashes when PyTorch uses the MPS backend.
|
||||
|
||||
The function has two code paths controlled by the ``_HINDSIGHT_DAEMON_CHILD``
|
||||
environment variable:
|
||||
|
||||
* **Parent** (env var not set): re-exec the same command via Popen with
|
||||
``start_new_session=True``, stripping ``--daemon`` from argv and setting
|
||||
``_HINDSIGHT_DAEMON_CHILD=1``. Then ``sys.exit(0)``.
|
||||
* **Child** (env var set): redirect stdio to the daemon log file and return.
|
||||
No fork, no re-exec.
|
||||
|
||||
On Windows there is no fork model: the spawning parent is expected to
|
||||
detach us via ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` and to
|
||||
redirect stdout/stderr to ``HINDSIGHT_API_DAEMON_LOG`` before exec.
|
||||
We still ensure the log directory exists.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
|
||||
# If we are already the daemon child (re-exec'd by a previous daemonize()
|
||||
# call, or launched by hindsight-embed with the env var set), just redirect
|
||||
# stdio and return — no re-exec needed.
|
||||
if os.environ.get(ENV_DAEMON_CHILD) == "1":
|
||||
_redirect_stdio_to_log()
|
||||
return
|
||||
|
||||
# --- Parent path: re-exec ourselves as a detached background process ---
|
||||
|
||||
# Build child command: same Python, same module entry point, all args
|
||||
# except --daemon (replaced by the env var).
|
||||
child_args = [a for a in sys.argv[1:] if a != "--daemon"]
|
||||
cmd = [sys.executable, "-m", "hindsight_api.main"] + child_args
|
||||
|
||||
env = os.environ.copy()
|
||||
env[ENV_DAEMON_CHILD] = "1"
|
||||
env["HINDSIGHT_API_DAEMON_LOG"] = str(DAEMON_LOG_PATH)
|
||||
|
||||
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(DAEMON_LOG_PATH, "ab") as log_handle:
|
||||
subprocess.Popen(cmd, env=env, **_detach_popen_kwargs(log_handle))
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
|
||||
"""Check if a daemon is running and responsive on the given port."""
|
||||
import socket
|
||||
|
||||
@@ -133,7 +133,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
max_concurrent: Maximum concurrent reranking calls (default: 2).
|
||||
Higher values may cause CPU thrashing under load.
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
force_cpu: Force CPU mode for local inference.
|
||||
Default: False
|
||||
trust_remote_code: Allow loading models with custom code (security risk).
|
||||
Required for some models like jina-reranker-v2-base-multilingual.
|
||||
|
||||
@@ -104,7 +104,7 @@ class LocalSTEmbeddings(Embeddings):
|
||||
Args:
|
||||
model_name: Name of the SentenceTransformer model to use.
|
||||
Default: BAAI/bge-small-en-v1.5
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
force_cpu: Force CPU mode for local inference.
|
||||
Default: False
|
||||
trust_remote_code: Allow loading models with custom code (security risk).
|
||||
Required for some models with custom architectures.
|
||||
|
||||
@@ -129,6 +129,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
|
||||
"none",
|
||||
"vertexai",
|
||||
"litellm",
|
||||
"litellmrouter",
|
||||
"bedrock",
|
||||
}
|
||||
)
|
||||
@@ -153,6 +154,7 @@ def create_llm_provider(
|
||||
vertexai_region: str | None = None,
|
||||
vertexai_credentials: Any = None,
|
||||
gemini_safety_settings: list | None = None,
|
||||
litellmrouter_config: dict[str, Any] | None = None,
|
||||
) -> Any: # Returns LLMInterface
|
||||
"""
|
||||
Factory function to create the appropriate LLM provider implementation.
|
||||
@@ -183,6 +185,7 @@ def create_llm_provider(
|
||||
CodexLLM,
|
||||
GeminiLLM,
|
||||
LiteLLMLLM,
|
||||
LiteLLMRouterLLM,
|
||||
LlamaCppLLM,
|
||||
MockLLM,
|
||||
NoneLLM,
|
||||
@@ -259,6 +262,23 @@ def create_llm_provider(
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "litellmrouter":
|
||||
if not litellmrouter_config:
|
||||
raise ValueError(
|
||||
"Provider 'litellmrouter' requires a config object. "
|
||||
"Set HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG (or the per-op variant) "
|
||||
"to a JSON object accepted by litellm.Router. "
|
||||
"See https://docs.litellm.ai/docs/routing."
|
||||
)
|
||||
return LiteLLMRouterLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
config=litellmrouter_config,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "bedrock":
|
||||
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
|
||||
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
|
||||
@@ -288,7 +308,18 @@ def create_llm_provider(
|
||||
extra_args=config.llamacpp_extra_args,
|
||||
)
|
||||
|
||||
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "deepseek", "volcano", "openrouter"):
|
||||
elif provider_lower in (
|
||||
"openai",
|
||||
"groq",
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"minimax",
|
||||
"deepseek",
|
||||
"volcano",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"opencode-go",
|
||||
):
|
||||
return OpenAICompatibleLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
@@ -323,6 +354,7 @@ class LLMProvider:
|
||||
gemini_safety_settings: list | None = None,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
litellmrouter_config: dict[str, Any] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize LLM provider.
|
||||
@@ -341,12 +373,18 @@ class LLMProvider:
|
||||
Used by operators routing through proxies / request-tracing middleware. Falls
|
||||
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
|
||||
when ``None``.
|
||||
litellmrouter_config: Provider-specific config for ``provider="litellmrouter"``.
|
||||
JSON object passed verbatim to ``litellm.Router(**config)`` — see
|
||||
https://docs.litellm.ai/docs/routing. Ignored unless ``provider == "litellmrouter"``.
|
||||
When None and the provider is ``litellmrouter``, falls back to
|
||||
``HindsightConfig.llm_litellmrouter_config``.
|
||||
"""
|
||||
self.provider = provider.lower()
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.litellmrouter_config = litellmrouter_config
|
||||
# Service tiers from hierarchical config (not env vars)
|
||||
self.groq_service_tier = groq_service_tier
|
||||
self.openai_service_tier = openai_service_tier
|
||||
@@ -383,9 +421,12 @@ class LLMProvider:
|
||||
"minimax",
|
||||
"deepseek",
|
||||
"litellm",
|
||||
"litellmrouter",
|
||||
"bedrock",
|
||||
"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)}")
|
||||
@@ -404,6 +445,10 @@ class LLMProvider:
|
||||
self.base_url = "https://api.deepseek.com"
|
||||
elif self.provider == "openrouter":
|
||||
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
|
||||
@@ -459,6 +504,19 @@ class LLMProvider:
|
||||
except Exception:
|
||||
pass # Config may not be initialized in test environments
|
||||
|
||||
# For litellmrouter: prefer an explicit chain from the caller (per-op
|
||||
# construction in MemoryEngine threads the right chain through). If the caller
|
||||
# didn't supply one, fall back to the global ``llm_litellmrouter_config`` so
|
||||
# ad-hoc constructions (e.g. ``LLMProvider.from_env()``) keep working.
|
||||
router_config: dict[str, Any] | None = self.litellmrouter_config
|
||||
if self.provider == "litellmrouter" and router_config is None:
|
||||
from ..config import _get_raw_config
|
||||
|
||||
try:
|
||||
router_config = _get_raw_config().llm_litellmrouter_config
|
||||
except Exception:
|
||||
router_config = None
|
||||
|
||||
# Create provider implementation using factory
|
||||
self._provider_impl = create_llm_provider(
|
||||
provider=self.provider,
|
||||
@@ -474,6 +532,7 @@ class LLMProvider:
|
||||
vertexai_region=vertexai_region,
|
||||
vertexai_credentials=vertexai_credentials,
|
||||
gemini_safety_settings=self.gemini_safety_settings,
|
||||
litellmrouter_config=router_config,
|
||||
)
|
||||
|
||||
# Backward compatibility: Keep mock provider properties
|
||||
@@ -781,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,
|
||||
@@ -789,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)
|
||||
@@ -802,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"))
|
||||
|
||||
|
||||
@@ -546,6 +546,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
model=memory_llm_model,
|
||||
extra_body=config.llm_extra_body,
|
||||
default_headers=config.llm_default_headers,
|
||||
litellmrouter_config=config.llm_litellmrouter_config,
|
||||
)
|
||||
|
||||
# Store client and model for convenience (deprecated: use _llm_config.call() instead)
|
||||
@@ -574,6 +575,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
model=retain_model,
|
||||
extra_body=config.llm_extra_body,
|
||||
default_headers=config.llm_default_headers,
|
||||
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
|
||||
)
|
||||
|
||||
# Reflect LLM config - for think/observe operations (can use lighter models)
|
||||
@@ -597,6 +599,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
model=reflect_model,
|
||||
extra_body=config.llm_extra_body,
|
||||
default_headers=config.llm_default_headers,
|
||||
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
|
||||
)
|
||||
|
||||
# Consolidation LLM config - for mental model consolidation (can use efficient models)
|
||||
@@ -620,6 +623,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
model=consolidation_model,
|
||||
extra_body=config.llm_extra_body,
|
||||
default_headers=config.llm_default_headers,
|
||||
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
|
||||
)
|
||||
|
||||
# Initialize cross-encoder reranker (cached for performance)
|
||||
@@ -1654,11 +1658,16 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Parent doesn't exist (shouldn't happen)
|
||||
return
|
||||
|
||||
# Get all sibling operations (including this one)
|
||||
# This query runs in the same transaction, so it sees the current child's updated status
|
||||
# Get all sibling operations (including this one).
|
||||
# This query runs in the same transaction, so it sees the current
|
||||
# child's updated status. Pull error_message too so a parent that
|
||||
# fails can inherit a representative child reason -- otherwise
|
||||
# downstream consumers (dashboards, alert filters) lose the actual
|
||||
# cause once a batch has children. See the worker poller's
|
||||
# _summarise_child_error_messages for the propagation rationale.
|
||||
siblings = await conn.fetch(
|
||||
f"""
|
||||
SELECT status
|
||||
SELECT status, error_message
|
||||
FROM {fq_table("async_operations")}
|
||||
WHERE bank_id = $1
|
||||
AND result_metadata::jsonb @> $2::jsonb
|
||||
@@ -1682,7 +1691,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# All siblings are done - update parent status
|
||||
if any_failed:
|
||||
new_status = "failed"
|
||||
# Set parent error message to indicate child failure
|
||||
# Set parent error message to indicate child failure. Inherit
|
||||
# the most-common failed-child error_message rather than a
|
||||
# generic string so downstream filters can attribute the
|
||||
# cause correctly.
|
||||
from hindsight_api.worker.poller import _summarise_child_error_messages
|
||||
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("async_operations")}
|
||||
@@ -1691,7 +1705,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
new_status,
|
||||
"One or more sub-batches failed",
|
||||
_summarise_child_error_messages(siblings),
|
||||
)
|
||||
elif all_completed:
|
||||
new_status = "completed"
|
||||
|
||||
@@ -9,6 +9,7 @@ from .claude_code_llm import ClaudeCodeLLM
|
||||
from .codex_llm import CodexLLM
|
||||
from .gemini_llm import GeminiLLM
|
||||
from .litellm_llm import LiteLLMLLM
|
||||
from .litellm_router_llm import LiteLLMRouterLLM
|
||||
from .llamacpp_llm import LlamaCppLLM
|
||||
from .mock_llm import MockLLM
|
||||
from .none_llm import NoneLLM
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
"GeminiLLM",
|
||||
"LlamaCppLLM",
|
||||
"LiteLLMLLM",
|
||||
"LiteLLMRouterLLM",
|
||||
"MockLLM",
|
||||
"NoneLLM",
|
||||
"OpenAICompatibleLLM",
|
||||
|
||||
@@ -12,6 +12,8 @@ import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
||||
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
||||
from hindsight_api.metrics import get_metrics_collector
|
||||
@@ -278,6 +280,12 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
|
||||
return result
|
||||
|
||||
except ValidationError:
|
||||
# Pydantic schema validation failure — retrying with the same
|
||||
# input won't produce a different schema. Raise immediately
|
||||
# instead of burning quota on identical calls (#1412).
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
@@ -103,12 +103,58 @@ class LiteLLMLLM(LLMInterface):
|
||||
if self.base_url:
|
||||
kwargs["api_base"] = self.base_url
|
||||
if max_completion_tokens is not None:
|
||||
kwargs["max_completion_tokens"] = max_completion_tokens
|
||||
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
return kwargs
|
||||
|
||||
# ── per-model output-tokens cap (shared with Router subclass) ────────────
|
||||
# Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
|
||||
# high-capacity models. When a configured deployment supports fewer
|
||||
# completion tokens (e.g. gpt-4.1-nano caps at 32768), the call would
|
||||
# otherwise be rejected. Cap pre-emptively using LiteLLM's per-model
|
||||
# registry so things work out of the box across the supported model set.
|
||||
|
||||
def _cap_max_completion_tokens(self, value: int) -> int:
|
||||
cap = self._get_model_output_cap()
|
||||
if cap and value > cap:
|
||||
logger.debug("capping max_completion_tokens %d -> %d for model %s", value, cap, self.model)
|
||||
return cap
|
||||
return value
|
||||
|
||||
def _get_model_output_cap(self) -> int | None:
|
||||
"""Return the configured model's max output tokens, per LiteLLM's registry."""
|
||||
try:
|
||||
cap = self._litellm.get_max_tokens(self.model)
|
||||
return int(cap) if cap else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── hooks for Router-style subclasses ────────────────────────────────────
|
||||
# The retry+parse loop in call() / call_with_tools() is shared by every
|
||||
# LiteLLM-backed provider. Subclasses override the small surface below to
|
||||
# swap the completion fn (direct vs Router) and rename the deployment that
|
||||
# actually answered the request.
|
||||
|
||||
@property
|
||||
def _stage_label(self) -> str:
|
||||
"""Stage breadcrumb label — overridden by subclasses (e.g. ``litellmrouter``)."""
|
||||
return "litellm"
|
||||
|
||||
async def _acompletion(self, **kwargs: Any) -> Any:
|
||||
"""Issue a chat completion. Subclasses override to route via ``litellm.Router``."""
|
||||
return await self._litellm.acompletion(**kwargs)
|
||||
|
||||
def _resolve_completion_model(self, response: Any) -> str:
|
||||
"""
|
||||
Return the model name to record in metrics/tracing.
|
||||
|
||||
For Router-backed providers this can differ from ``self.model`` — the Router
|
||||
may pick a different deployment than the primary. Default: ``self.model``.
|
||||
"""
|
||||
return self.model
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
@@ -143,12 +189,13 @@ class LiteLLMLLM(LLMInterface):
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
if attempt > 0:
|
||||
set_stage(f"llm.litellm.{scope}.attempt={attempt + 1}/{max_retries + 1}")
|
||||
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
|
||||
try:
|
||||
response = await self._litellm.acompletion(**call_kwargs)
|
||||
response = await self._acompletion(**call_kwargs)
|
||||
|
||||
content = response.choices[0].message.content or ""
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
model_name = self._resolve_completion_model(response)
|
||||
|
||||
# Check for length-limited output
|
||||
if finish_reason == "length":
|
||||
@@ -184,7 +231,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
model=model_name,
|
||||
scope=scope,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
@@ -198,7 +245,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
span_recorder = get_span_recorder()
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
model=model_name,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=_serialize_for_span(result),
|
||||
@@ -211,7 +258,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
|
||||
if duration > 10.0:
|
||||
logger.info(
|
||||
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
|
||||
f"slow llm call: scope={scope}, model={self.provider}/{model_name}, "
|
||||
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
@@ -287,13 +334,14 @@ class LiteLLMLLM(LLMInterface):
|
||||
last_exception = None
|
||||
for attempt in range(max_retries + 1):
|
||||
if attempt > 0:
|
||||
set_stage(f"llm.litellm.tools.attempt={attempt + 1}/{max_retries + 1}")
|
||||
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
|
||||
try:
|
||||
response = await self._litellm.acompletion(**call_kwargs)
|
||||
response = await self._acompletion(**call_kwargs)
|
||||
|
||||
message = response.choices[0].message
|
||||
content = message.content
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
model_name = self._resolve_completion_model(response)
|
||||
|
||||
# Extract tool calls
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
@@ -319,7 +367,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
model=model_name,
|
||||
scope=scope,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
@@ -338,7 +386,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
)
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
model=model_name,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=content,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
LiteLLM Router LLM provider — pure pass-through to ``litellm.Router``.
|
||||
|
||||
The full configuration object is forwarded verbatim. We do not translate model
|
||||
names, infer fallbacks, validate shape, or introspect Router internals:
|
||||
whatever the user puts in ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG`` becomes
|
||||
``Router(**config)``. If the shape is wrong, LiteLLM Router raises.
|
||||
|
||||
The only Hindsight-imposed convention is that one entry in ``model_list``
|
||||
must have ``model_name: "default"`` — that's the entrypoint we issue
|
||||
completions against. Everything else (ordering, fallbacks, load-balancing,
|
||||
weighted picks, rate limits, retries, cooldowns) is whatever the user
|
||||
configures via LiteLLM's own keys.
|
||||
|
||||
See https://docs.litellm.ai/docs/routing for the supported keys (``model_list``,
|
||||
``fallbacks``, ``context_window_fallbacks``, ``num_retries``, ``cooldown_time``,
|
||||
``routing_strategy``, ``allowed_fails``, …).
|
||||
|
||||
The retry/parse/metrics loop is shared with ``LiteLLMLLM`` via inheritance:
|
||||
this class only overrides the completion fn, the call kwargs, and the model
|
||||
name reported in metrics.
|
||||
|
||||
Example ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG``::
|
||||
|
||||
{
|
||||
"model_list": [
|
||||
{"model_name": "default", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-..."}},
|
||||
{"model_name": "fallback", "litellm_params": {"model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-..."}}
|
||||
],
|
||||
"fallbacks": [{"default": ["fallback"]}],
|
||||
"num_retries": 0,
|
||||
"cooldown_time": 60
|
||||
}
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Hindsight always issues completions against this ``model_name``. Users must
|
||||
# include at least one entry with ``model_name: "default"`` in their config's
|
||||
# ``model_list``; that entry is the entrypoint, and any other entries become
|
||||
# fallback / load-balance / weighted-pool members per the user's own
|
||||
# ``fallbacks`` / ``routing_strategy`` settings.
|
||||
_ENTRYPOINT_MODEL_NAME = "default"
|
||||
|
||||
|
||||
class LiteLLMRouterLLM(LiteLLMLLM):
|
||||
"""
|
||||
LLM provider backed by ``litellm.Router``.
|
||||
|
||||
The full Router config is supplied by the caller. We pass it verbatim to
|
||||
``Router(**config)`` and route requests against the first ``model_list``
|
||||
entry's ``model_name``. Inherits the retry/parse/metrics loop from
|
||||
``LiteLLMLLM``; only the completion fn and the call kwargs differ.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
config: dict[str, Any],
|
||||
reasoning_effort: str = "low",
|
||||
timeout: float = 300.0,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
self.config = config
|
||||
|
||||
from litellm import Router
|
||||
|
||||
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
|
||||
# Pure pass-through: whatever the user gave goes straight to LiteLLM Router.
|
||||
# If the shape is invalid, Router raises its own error — we don't pre-validate
|
||||
# or introspect Router internals.
|
||||
self._router = Router(**config)
|
||||
|
||||
# Pre-compute the most conservative output-tokens cap across every configured
|
||||
# deployment so a single max_completion_tokens value works no matter which
|
||||
# deployment Router picks. Uses LiteLLM's own per-model registry; unknown
|
||||
# models contribute no cap. See LiteLLMLLM._cap_max_completion_tokens.
|
||||
self._router_output_cap = self._compute_router_output_cap(config)
|
||||
|
||||
logger.info("LiteLLM Router initialized; entrypoint model_name=%r", _ENTRYPOINT_MODEL_NAME)
|
||||
|
||||
def _compute_router_output_cap(self, config: dict[str, Any]) -> int | None:
|
||||
caps: list[int] = []
|
||||
for deployment in (config.get("model_list") or []) if isinstance(config, dict) else []:
|
||||
if not isinstance(deployment, dict):
|
||||
continue
|
||||
params = deployment.get("litellm_params") or {}
|
||||
model_str = params.get("model") if isinstance(params, dict) else None
|
||||
if not model_str:
|
||||
continue
|
||||
try:
|
||||
cap = self._litellm.get_max_tokens(model_str)
|
||||
except Exception:
|
||||
cap = None
|
||||
if cap:
|
||||
caps.append(int(cap))
|
||||
return min(caps) if caps else None
|
||||
|
||||
# ── overrides for the shared retry/parse loop ───────────────────────────
|
||||
|
||||
@property
|
||||
def _stage_label(self) -> str:
|
||||
return "litellmrouter"
|
||||
|
||||
async def _acompletion(self, **kwargs: Any) -> Any:
|
||||
return await self._router.acompletion(**kwargs)
|
||||
|
||||
def _resolve_completion_model(self, response: Any) -> str:
|
||||
hidden = getattr(response, "_hidden_params", None) or {}
|
||||
return hidden.get("model") or _ENTRYPOINT_MODEL_NAME
|
||||
|
||||
def _get_model_output_cap(self) -> int | None:
|
||||
return self._router_output_cap
|
||||
|
||||
def _build_common_kwargs(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# Always issue against the entrypoint group; Router handles deployment selection,
|
||||
# cross-group fallbacks, retries, cooldowns — whatever the user configured.
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": _ENTRYPOINT_MODEL_NAME,
|
||||
"messages": messages,
|
||||
}
|
||||
if max_completion_tokens is not None:
|
||||
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
return kwargs
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
from hindsight_api.engine.llm_interface import OutputTooLongError
|
||||
|
||||
try:
|
||||
await self.call(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
max_completion_tokens=50,
|
||||
temperature=0.0,
|
||||
scope="verification",
|
||||
max_retries=0,
|
||||
)
|
||||
logger.info("LiteLLM Router connection verified successfully")
|
||||
except OutputTooLongError:
|
||||
logger.info("LiteLLM Router connection verified successfully (response truncated)")
|
||||
except Exception as e:
|
||||
logger.error(f"LiteLLM Router connection verification failed: {e}")
|
||||
raise RuntimeError(f"Failed to verify LiteLLM Router connection: {e}") from e
|
||||
@@ -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.
|
||||
@@ -273,6 +276,8 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
"deepseek",
|
||||
"volcano",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"opencode-go",
|
||||
]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
|
||||
@@ -291,13 +296,29 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
self.base_url = "https://api.deepseek.com"
|
||||
elif self.provider == "openrouter":
|
||||
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") 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)
|
||||
@@ -558,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))
|
||||
@@ -7,6 +7,7 @@ Implements hierarchical retrieval:
|
||||
3. recall - Raw facts as ground truth
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import replace
|
||||
@@ -22,6 +23,21 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _document_metadata_from_retain_params(retain_params: Any) -> dict[str, Any] | None:
|
||||
"""Return document metadata stored under retain_params.metadata."""
|
||||
if isinstance(retain_params, str):
|
||||
try:
|
||||
retain_params = json.loads(retain_params)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(retain_params, dict):
|
||||
return None
|
||||
|
||||
metadata = retain_params.get("metadata")
|
||||
return metadata if isinstance(metadata, dict) else None
|
||||
|
||||
|
||||
async def tool_search_mental_models(
|
||||
memory_engine: "MemoryEngine",
|
||||
conn: "Connection",
|
||||
@@ -350,7 +366,7 @@ async def tool_expand(
|
||||
if all_doc_ids:
|
||||
docs = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, original_text, metadata, retain_params
|
||||
SELECT id, original_text, retain_params
|
||||
FROM {fq_table("documents")}
|
||||
WHERE id = ANY($1) AND bank_id = $2
|
||||
""",
|
||||
@@ -396,7 +412,7 @@ async def tool_expand(
|
||||
item["document"] = {
|
||||
"id": doc["id"],
|
||||
"full_text": doc["original_text"],
|
||||
"metadata": doc["metadata"],
|
||||
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
|
||||
"retain_params": doc["retain_params"],
|
||||
}
|
||||
elif memory["document_id"] and depth == "document" and memory["document_id"] in doc_map:
|
||||
@@ -405,7 +421,7 @@ async def tool_expand(
|
||||
item["document"] = {
|
||||
"id": doc["id"],
|
||||
"full_text": doc["original_text"],
|
||||
"metadata": doc["metadata"],
|
||||
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
|
||||
"retain_params": doc["retain_params"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1382,14 +1382,9 @@ async def _extract_facts_from_chunk(
|
||||
f" (current value: {config.retain_max_completion_tokens}, must be > RETAIN_CHUNK_SIZE={config.retain_chunk_size})"
|
||||
) from e
|
||||
|
||||
if "json_validate_failed" in str(e):
|
||||
logger.warning(
|
||||
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{llm_max_retries} failed with JSON validation error: {e}"
|
||||
)
|
||||
if attempt < llm_max_retries - 1:
|
||||
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
|
||||
continue
|
||||
# If it's not a JSON validation error or we're out of retries, re-raise
|
||||
# Don't retry json_validate_failed here — the inner provider
|
||||
# loop already retried the 400 error. Re-entering the LLM call
|
||||
# with the same input just multiplies wasted calls.
|
||||
raise
|
||||
|
||||
# If we exhausted all retries, raise the last error or a descriptive fallback
|
||||
@@ -1562,47 +1557,25 @@ async def extract_facts_from_text(
|
||||
f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
|
||||
)
|
||||
|
||||
# Per-chunk retry wrapper: each chunk gets up to MAX_CHUNK_RETRIES attempts.
|
||||
# This handles transient LLM failures (timeouts, rate limits, malformed responses)
|
||||
# without discarding the entire batch. If a chunk still fails after all retries,
|
||||
# the ENTIRE retain fails — we do not accept partial extraction.
|
||||
MAX_CHUNK_RETRIES = 3
|
||||
CHUNK_RETRY_BASE_DELAY = 2.0 # seconds, doubles each retry
|
||||
|
||||
async def _extract_chunk_with_retry(chunk: str, chunk_index: int) -> tuple:
|
||||
"""Extract facts from a single chunk with retries on failure."""
|
||||
last_exception = None
|
||||
for attempt in range(MAX_CHUNK_RETRIES):
|
||||
try:
|
||||
return await _extract_facts_with_auto_split(
|
||||
chunk=chunk,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=len(chunks),
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < MAX_CHUNK_RETRIES - 1:
|
||||
delay = CHUNK_RETRY_BASE_DELAY * (2**attempt)
|
||||
logger.warning(
|
||||
f"Chunk {chunk_index}/{len(chunks)} extraction failed "
|
||||
f"(attempt {attempt + 1}/{MAX_CHUNK_RETRIES}): "
|
||||
f"{type(e).__name__}. Retrying in {delay:.0f}s..."
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.error(
|
||||
f"Chunk {chunk_index}/{len(chunks)} extraction failed after "
|
||||
f"{MAX_CHUNK_RETRIES} attempts: {type(e).__name__}: {e}"
|
||||
)
|
||||
raise last_exception
|
||||
|
||||
tasks = [_extract_chunk_with_retry(chunk, i) for i, chunk in enumerate(chunks)]
|
||||
# Transient LLM failures (timeouts, rate limits) are already retried inside
|
||||
# the provider's inner loop. Content-quality retries (malformed facts) are
|
||||
# handled by the middle loop in _extract_facts_from_chunk. Adding a third
|
||||
# retry layer here would multiply wasted calls on deterministic failures
|
||||
# (see https://github.com/vectorize-io/hindsight/issues/1412).
|
||||
tasks = [
|
||||
_extract_facts_with_auto_split(
|
||||
chunk=chunk,
|
||||
chunk_index=i,
|
||||
total_chunks=len(chunks),
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
metadata=metadata,
|
||||
)
|
||||
for i, chunk in enumerate(chunks)
|
||||
]
|
||||
|
||||
# return_exceptions=True so we can collect all results even if some chunks
|
||||
# exhausted their retries. We check for failures below and fail the retain
|
||||
@@ -1628,8 +1601,8 @@ async def extract_facts_from_text(
|
||||
# hasn't committed yet. The worker poller will retry the entire task.
|
||||
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
|
||||
raise RuntimeError(
|
||||
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed "
|
||||
f"after {MAX_CHUNK_RETRIES} retries each. First failures: {failed_summary}"
|
||||
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. "
|
||||
f"First failures: {failed_summary}"
|
||||
)
|
||||
|
||||
return all_facts, chunk_metadata, total_usage
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from .config import DEFAULT_WORKERS, ENV_HOST, ENV_WORKERS, HindsightConfig, _ge
|
||||
from .daemon import (
|
||||
DEFAULT_DAEMON_PORT,
|
||||
DEFAULT_IDLE_TIMEOUT,
|
||||
ENV_DAEMON_CHILD,
|
||||
IdleTimeoutMiddleware,
|
||||
daemonize,
|
||||
)
|
||||
@@ -150,8 +151,15 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Daemon mode handling
|
||||
if args.daemon:
|
||||
# Daemon mode handling.
|
||||
# is_daemon_child is True when we are the re-exec'd child spawned by
|
||||
# daemonize() or by hindsight-embed's DaemonEmbedManager. The child
|
||||
# does not have --daemon in its argv, but must still behave as a daemon
|
||||
# (resolve host/port, enable idle timeout, suppress banner, etc.).
|
||||
is_daemon_child = os.environ.get(ENV_DAEMON_CHILD) == "1"
|
||||
is_daemon = args.daemon or is_daemon_child
|
||||
|
||||
if is_daemon:
|
||||
args.host, args.port = resolve_daemon_host_port(
|
||||
args_host=args.host,
|
||||
args_port=args.port,
|
||||
@@ -159,12 +167,13 @@ def main():
|
||||
config_port=config.port,
|
||||
)
|
||||
|
||||
# Fork into background
|
||||
# No lockfile needed - port binding prevents duplicate daemons
|
||||
# Detach into background (parent re-execs and exits; child redirects
|
||||
# stdio to log file). No lockfile needed — port binding prevents
|
||||
# duplicate daemons.
|
||||
daemonize()
|
||||
|
||||
# Print banner (not in daemon mode)
|
||||
if not args.daemon:
|
||||
if not is_daemon:
|
||||
print()
|
||||
print_banner()
|
||||
|
||||
@@ -173,7 +182,7 @@ def main():
|
||||
if args.log_level != config.log_level:
|
||||
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
if not is_daemon:
|
||||
config.log_config()
|
||||
|
||||
# Register cleanup handlers
|
||||
@@ -222,7 +231,7 @@ def main():
|
||||
|
||||
# Wrap with idle timeout middleware in daemon mode
|
||||
idle_middleware = None
|
||||
if args.daemon:
|
||||
if is_daemon:
|
||||
idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
|
||||
app = idle_middleware
|
||||
|
||||
@@ -277,7 +286,7 @@ def main():
|
||||
uvicorn_config["ssl_certfile"] = args.ssl_certfile
|
||||
|
||||
# Print startup info (not in daemon mode)
|
||||
if not args.daemon:
|
||||
if not is_daemon:
|
||||
from .banner import print_startup_info
|
||||
|
||||
print_startup_info(
|
||||
|
||||
@@ -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 $$;
|
||||
""")
|
||||
)
|
||||
|
||||
@@ -14,7 +14,8 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections import Counter
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -37,6 +38,36 @@ STUCK_STACK_INITIAL_THRESHOLD_S = 300
|
||||
STUCK_STACK_MAX_THRESHOLD_S = 3600 * 6 # cap doubling at 6h
|
||||
|
||||
|
||||
def _summarise_child_error_messages(siblings: "Iterable[Any]") -> str:
|
||||
"""Pick a representative error message for a parent whose children failed.
|
||||
|
||||
Used when a batch_retain parent transitions to 'failed' because at least
|
||||
one child sub-batch failed. Without this, the parent gets a generic
|
||||
"One or more sub-batches failed" string and any consumer that reasons
|
||||
about errors via error_message (dashboards, alert filters, log
|
||||
aggregators) loses the actual cause -- a class of failures that all
|
||||
share the same root reason at the child level becomes indistinguishable
|
||||
at the parent level.
|
||||
|
||||
Strategy: pick the most common non-empty error_message among failed
|
||||
siblings. If they all failed for the same reason (the common case), the
|
||||
parent inherits that reason verbatim. If they vary, the most-common one
|
||||
is still a useful representative. Falls back to the legacy generic
|
||||
string when no failed sibling carries an error_message at all.
|
||||
"""
|
||||
failed_errors: list[str] = []
|
||||
for s in siblings:
|
||||
if s["status"] != "failed":
|
||||
continue
|
||||
msg = (s["error_message"] or "").strip()
|
||||
if msg:
|
||||
failed_errors.append(msg)
|
||||
if not failed_errors:
|
||||
return "One or more sub-batches failed"
|
||||
most_common, _count = Counter(failed_errors).most_common(1)[0]
|
||||
return most_common
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveTaskInfo:
|
||||
"""Tracking info for an in-flight worker task.
|
||||
@@ -495,10 +526,14 @@ class WorkerPoller:
|
||||
if not parent_row:
|
||||
return
|
||||
|
||||
# Check whether all siblings are done
|
||||
# Check whether all siblings are done. Pull error_message too so a
|
||||
# parent that fails can inherit a representative child reason --
|
||||
# otherwise the parent's error_message is generic ("One or more
|
||||
# sub-batches failed") and downstream consumers (dashboards, alerts,
|
||||
# filters) lose the actual cause once a batch has children.
|
||||
siblings = await conn.fetch(
|
||||
f"""
|
||||
SELECT status FROM {table}
|
||||
SELECT status, error_message FROM {table}
|
||||
WHERE bank_id = $1
|
||||
AND result_metadata::jsonb @> $2::jsonb
|
||||
""",
|
||||
@@ -517,7 +552,7 @@ class WorkerPoller:
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
"One or more sub-batches failed",
|
||||
_summarise_child_error_messages(siblings),
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
@@ -940,15 +975,30 @@ class WorkerPoller:
|
||||
if len(processing_info) > 10:
|
||||
processing_str += f" +{len(processing_info) - 10} more"
|
||||
|
||||
# Get global stats from DB
|
||||
# Get global stats from DB — scope the heavy COUNT/GROUP BY
|
||||
# queries to schemas that actually have work. With N tenants the
|
||||
# full fanout is 2*N queries every PROGRESS_LOG_INTERVAL; scoping
|
||||
# via the routine (or per-schema EXISTS fallback) reduces this to
|
||||
# 2*active_schemas which is typically << N.
|
||||
schemas = await self._get_schemas()
|
||||
total_schema_count = len(schemas)
|
||||
|
||||
# Schemas with pending async_operations (uses server-side
|
||||
# routine when installed, falls back to per-schema EXISTS).
|
||||
schemas_with_pending = await self._scan_active_schemas(schemas)
|
||||
|
||||
# Also include schemas that have in-flight tasks on this worker
|
||||
# so the "processing" worker_id GROUP BY still reports correctly.
|
||||
schemas_with_active_tasks = {info.schema for info in active_tasks.values()}
|
||||
schemas_to_query = schemas_with_pending | schemas_with_active_tasks
|
||||
|
||||
global_pending = 0
|
||||
all_worker_counts: dict[str, int] = {}
|
||||
# operation_type -> aggregated bucket counts across schemas
|
||||
pending_breakdown: dict[str, dict[str, int]] = {}
|
||||
|
||||
async with self._backend.acquire() as conn:
|
||||
for schema in schemas:
|
||||
for schema in schemas_to_query:
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
# Bucket pending rows by the same predicates the claim query
|
||||
@@ -957,20 +1007,24 @@ class WorkerPoller:
|
||||
# retry backoff, etc.).
|
||||
# Use SUM(CASE WHEN ...) instead of COUNT(*) FILTER (WHERE ...)
|
||||
# for Oracle compatibility — FILTER is PG-specific.
|
||||
breakdown_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
operation_type,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
|
||||
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
|
||||
THEN 1 ELSE 0 END) AS retry_blocked,
|
||||
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
|
||||
FROM {table}
|
||||
WHERE status = 'pending'
|
||||
GROUP BY operation_type
|
||||
"""
|
||||
)
|
||||
try:
|
||||
breakdown_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
operation_type,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
|
||||
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
|
||||
THEN 1 ELSE 0 END) AS retry_blocked,
|
||||
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
|
||||
FROM {table}
|
||||
WHERE status = 'pending'
|
||||
GROUP BY operation_type
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
# Schema may be partially provisioned (table missing).
|
||||
breakdown_rows = []
|
||||
for br in breakdown_rows:
|
||||
op_type = br["operation_type"] or "unknown"
|
||||
bucket = pending_breakdown.setdefault(
|
||||
@@ -982,14 +1036,17 @@ class WorkerPoller:
|
||||
bucket["assigned"] += br["assigned"]
|
||||
global_pending += br["total"]
|
||||
|
||||
worker_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT worker_id, COUNT(*) as count
|
||||
FROM {table}
|
||||
WHERE status = 'processing'
|
||||
GROUP BY worker_id
|
||||
"""
|
||||
)
|
||||
try:
|
||||
worker_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT worker_id, COUNT(*) as count
|
||||
FROM {table}
|
||||
WHERE status = 'processing'
|
||||
GROUP BY worker_id
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
worker_rows = []
|
||||
for wr in worker_rows:
|
||||
wid = wr["worker_id"] or "unknown"
|
||||
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
|
||||
@@ -1005,14 +1062,19 @@ class WorkerPoller:
|
||||
pool_str = self._format_pool_stats()
|
||||
proc_str = self._format_proc_stats()
|
||||
|
||||
# Display None as "default" in logs
|
||||
schemas_str = ", ".join(s if s else "default" for s in schemas)
|
||||
queried_count = len(schemas_to_query)
|
||||
# Display queried schemas (cap at 20 for readability)
|
||||
queried_list = sorted(s if s else "default" for s in schemas_to_query)
|
||||
schemas_str = ", ".join(queried_list[:20])
|
||||
if len(queried_list) > 20:
|
||||
schemas_str += f" +{len(queried_list) - 20} more"
|
||||
logger.info(
|
||||
f"[WORKER_STATS] worker={self._worker_id} "
|
||||
f"slots={in_flight}/{self._max_slots} | "
|
||||
f"reserved: [{reserved_str}] | "
|
||||
f"shared={tasks_in_shared}/{shared_pool_size}(avail={shared_available}) | "
|
||||
f"global: pending={global_pending} (schemas: {schemas_str}) | "
|
||||
f"global: pending={global_pending} "
|
||||
f"(queried={queried_count}/{total_schema_count} schemas: {schemas_str}) | "
|
||||
f"others: {others_str} | "
|
||||
f"pool: {pool_str} | "
|
||||
f"proc: {proc_str} | "
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api-slim"
|
||||
version = "0.6.0"
|
||||
version = "0.6.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -47,14 +47,14 @@ dependencies = [
|
||||
"anthropic>=0.40.0",
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
"litellm>=1.83.0", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789
|
||||
"litellm>=1.83.14", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789 / GHSA-pq44-5pcq-4r5g / GHSA-8cjq-wjmh-q42r
|
||||
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
|
||||
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
|
||||
"winloop>=0.1.0; sys_platform == 'win32'",
|
||||
"uvloop>=0.22.1; sys_platform != 'win32'",
|
||||
# Transitive dependency security fixes
|
||||
"pyasn1>=0.6.3", # DoS vulnerability fix
|
||||
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
|
||||
"urllib3>=2.7.0", # Decompression-bomb safeguards bypass + sensitive header forwarding fixes
|
||||
"langchain-core>=1.2.22", # Path traversal in legacy load_prompt functions fix
|
||||
"langsmith>=0.6.3", # SSRF via tracing header injection fix
|
||||
"protobuf>=6.33.5", # JSON recursion depth bypass fix
|
||||
|
||||
@@ -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,88 @@
|
||||
"""Tests for daemonize() — subprocess.Popen re-exec instead of os.fork()."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_daemonize_parent_reexecs_via_popen(monkeypatch, tmp_path):
|
||||
"""Parent path: daemonize() must spawn a child via subprocess.Popen and exit."""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.delenv("_HINDSIGHT_DAEMON_CHILD", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["hindsight-api", "--daemon", "--port", "9999"])
|
||||
|
||||
log_path = tmp_path / "daemon.log"
|
||||
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["kwargs"] = kwargs
|
||||
proc = MagicMock()
|
||||
proc.pid = 99999
|
||||
return proc
|
||||
|
||||
with (
|
||||
patch("hindsight_api.daemon.subprocess.Popen", side_effect=fake_popen),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
from hindsight_api.daemon import daemonize
|
||||
|
||||
daemonize()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
# Verify child command does NOT contain --daemon
|
||||
assert "--daemon" not in captured["cmd"]
|
||||
# Verify it uses the module entry point
|
||||
assert "-m" in captured["cmd"]
|
||||
assert "hindsight_api.main" in captured["cmd"]
|
||||
# Verify remaining args are preserved
|
||||
assert "--port" in captured["cmd"]
|
||||
assert "9999" in captured["cmd"]
|
||||
|
||||
# Verify env has the daemon child marker
|
||||
env = captured["kwargs"]["env"]
|
||||
assert env["_HINDSIGHT_DAEMON_CHILD"] == "1"
|
||||
|
||||
# Verify detach kwargs
|
||||
kwargs = captured["kwargs"]
|
||||
assert kwargs.get("start_new_session") is True
|
||||
|
||||
|
||||
def test_daemonize_child_does_not_reexec(monkeypatch, tmp_path):
|
||||
"""Child path: when _HINDSIGHT_DAEMON_CHILD=1, daemonize() does NOT call
|
||||
Popen — it only redirects stdio."""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setenv("_HINDSIGHT_DAEMON_CHILD", "1")
|
||||
|
||||
log_path = tmp_path / "daemon.log"
|
||||
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
|
||||
|
||||
with (
|
||||
patch("hindsight_api.daemon.subprocess.Popen") as mock_popen,
|
||||
patch("hindsight_api.daemon._redirect_stdio_to_log") as mock_redirect,
|
||||
):
|
||||
from hindsight_api.daemon import daemonize
|
||||
|
||||
daemonize()
|
||||
mock_popen.assert_not_called()
|
||||
mock_redirect.assert_called_once()
|
||||
|
||||
|
||||
def test_daemonize_windows_noop(monkeypatch, tmp_path):
|
||||
"""On Windows, daemonize() just creates the log directory."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
|
||||
log_path = tmp_path / "subdir" / "daemon.log"
|
||||
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
|
||||
|
||||
with patch("hindsight_api.daemon.subprocess.Popen") as mock_popen:
|
||||
from hindsight_api.daemon import daemonize
|
||||
|
||||
daemonize()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
assert log_path.parent.exists()
|
||||
@@ -337,7 +337,21 @@ I prefer presenting in person rather than virtually because I can read the room
|
||||
has_emotional = any(term in all_facts_text for term in [
|
||||
"thrilled", "positive feedback", "positive", "feedback", "enthusiastic"
|
||||
])
|
||||
assert has_emotional, "Should preserve emotional dimension"
|
||||
|
||||
# Check preference - should capture the in-person vs virtual preference
|
||||
has_preference = any(term in all_facts_text for term in [
|
||||
"prefer", "rather than", "in person", "in-person", "virtually",
|
||||
"read the room", "face-to-face", "face to face", "remote",
|
||||
])
|
||||
|
||||
# MAT bar: at least one of emotional or preferential must be preserved.
|
||||
# Smaller models (e.g. nova-2-lite) may compress both sentences into a
|
||||
# single fact that only captures one dimension — that's acceptable for
|
||||
# a minimum-acceptance test.
|
||||
assert has_emotional or has_preference, (
|
||||
f"Should preserve at least one of emotional or preferential dimension. "
|
||||
f"Extracted facts: {all_facts_text}"
|
||||
)
|
||||
|
||||
# Check no vague temporal terms
|
||||
prohibited_terms = ["recently", "soon", "lately"]
|
||||
@@ -345,12 +359,6 @@ I prefer presenting in person rather than virtually because I can read the room
|
||||
assert len(found_prohibited) == 0, \
|
||||
f"Should NOT use vague temporal terms. Found: {found_prohibited}"
|
||||
|
||||
# Check preference - should capture the in-person vs virtual preference
|
||||
has_preference = any(term in all_facts_text for term in [
|
||||
"prefer", "rather than", "in person", "virtually", "read the room"
|
||||
])
|
||||
assert has_preference, "Should preserve preferential dimension"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEMPORAL CONVERSION TESTS
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Tests for the LiteLLM Router LLM provider — config parsing, factory dispatch,
|
||||
and the Router-backed call paths (plain text, structured output, tool calls,
|
||||
retry on transient failure).
|
||||
|
||||
The provider is a thin pass-through to ``litellm.Router``. The chain config
|
||||
shape mirrors LiteLLM's API; we don't translate model names or impose
|
||||
fallbacks. See https://docs.litellm.ai/docs/routing.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hindsight_api.config import (
|
||||
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG,
|
||||
ENV_LLM_LITELLMROUTER_CONFIG,
|
||||
ENV_LLM_PROVIDER,
|
||||
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG,
|
||||
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG,
|
||||
HindsightConfig,
|
||||
_parse_llm_router_config,
|
||||
)
|
||||
from hindsight_api.engine.llm_wrapper import create_llm_provider
|
||||
from hindsight_api.engine.providers.litellm_router_llm import LiteLLMRouterLLM
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_step_config() -> dict[str, Any]:
|
||||
"""Raw LiteLLM Router config: two deployments wired for ordered fallback.
|
||||
|
||||
Hindsight always issues completions against ``model_name="default"``;
|
||||
additional groups become fallback / load-balance pool members per the
|
||||
user's ``fallbacks`` / ``routing_strategy`` settings.
|
||||
"""
|
||||
return {
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "default",
|
||||
"litellm_params": {
|
||||
"model": "openai/MiniMax-M2.7",
|
||||
"api_key": "sk-primary",
|
||||
"api_base": "https://api.minimax.io/v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "fallback",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-fallback"},
|
||||
},
|
||||
],
|
||||
"fallbacks": [{"default": ["fallback"]}],
|
||||
"num_retries": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_router_response() -> MagicMock:
|
||||
response = MagicMock()
|
||||
choice = MagicMock()
|
||||
choice.message.content = "ok"
|
||||
choice.message.tool_calls = None
|
||||
choice.finish_reason = "stop"
|
||||
response.choices = [choice]
|
||||
response.usage.prompt_tokens = 12
|
||||
response.usage.completion_tokens = 3
|
||||
response._hidden_params = {"model": "openai/gpt-4o-mini"}
|
||||
return response
|
||||
|
||||
|
||||
# --- config parsing ----------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseRouterConfig:
|
||||
def test_unset_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
|
||||
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
|
||||
|
||||
def test_empty_string_returns_none(self, monkeypatch):
|
||||
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, " ")
|
||||
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
|
||||
|
||||
def test_valid_config_passes_through(self, monkeypatch, two_step_config):
|
||||
"""Whatever the user provides round-trips verbatim — no translation."""
|
||||
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
|
||||
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == two_step_config
|
||||
|
||||
def test_invalid_json(self, monkeypatch):
|
||||
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, "{not json")
|
||||
with pytest.raises(ValueError, match="invalid JSON"):
|
||||
_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG)
|
||||
|
||||
def test_no_shape_validation(self, monkeypatch):
|
||||
"""We don't validate the shape — anything that parses as JSON gets passed through.
|
||||
|
||||
LiteLLM Router is authoritative for shape errors; we let them surface at
|
||||
Router construction time rather than pre-validating.
|
||||
"""
|
||||
# A list, a string, an object with junk keys — all accepted by the parser.
|
||||
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps([{"hello": "world"}]))
|
||||
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == [{"hello": "world"}]
|
||||
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps({"only": "garbage"}))
|
||||
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == {"only": "garbage"}
|
||||
|
||||
|
||||
class TestFromEnvLoadsConfig:
|
||||
def test_loaded_when_provider_is_litellmrouter(self, monkeypatch, two_step_config):
|
||||
monkeypatch.setenv(ENV_LLM_PROVIDER, "litellmrouter")
|
||||
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
|
||||
cfg = HindsightConfig.from_env()
|
||||
assert cfg.llm_provider == "litellmrouter"
|
||||
assert cfg.llm_litellmrouter_config == two_step_config
|
||||
|
||||
def test_unset_keeps_default_provider(self, monkeypatch):
|
||||
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
|
||||
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
|
||||
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
|
||||
cfg = HindsightConfig.from_env()
|
||||
assert cfg.llm_provider == "openai"
|
||||
assert cfg.llm_litellmrouter_config is None
|
||||
|
||||
def test_per_op_configs_independent(self, monkeypatch):
|
||||
"""Per-op env vars populate per-op fields without touching the default."""
|
||||
retain_config = {
|
||||
"model_list": [{"model_name": "r", "litellm_params": {"model": "openai/retain", "api_key": "rk"}}]
|
||||
}
|
||||
reflect_config = {
|
||||
"model_list": [{"model_name": "f", "litellm_params": {"model": "anthropic/claude", "api_key": "ak"}}]
|
||||
}
|
||||
consol_config = {
|
||||
"model_list": [{"model_name": "c", "litellm_params": {"model": "openai/consol", "api_key": "ck"}}]
|
||||
}
|
||||
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
|
||||
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
|
||||
monkeypatch.setenv(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG, json.dumps(retain_config))
|
||||
monkeypatch.setenv(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG, json.dumps(reflect_config))
|
||||
monkeypatch.setenv(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG, json.dumps(consol_config))
|
||||
cfg = HindsightConfig.from_env()
|
||||
assert cfg.llm_litellmrouter_config is None
|
||||
assert cfg.retain_llm_litellmrouter_config == retain_config
|
||||
assert cfg.reflect_llm_litellmrouter_config == reflect_config
|
||||
assert cfg.consolidation_llm_litellmrouter_config == consol_config
|
||||
|
||||
|
||||
# --- factory dispatch --------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactoryDispatch:
|
||||
def test_router_provider_requires_config(self):
|
||||
with pytest.raises(ValueError, match="config object"):
|
||||
create_llm_provider(
|
||||
provider="litellmrouter",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="unused",
|
||||
reasoning_effort="low",
|
||||
litellmrouter_config=None,
|
||||
)
|
||||
|
||||
def test_router_provider_returns_router_impl(self, two_step_config):
|
||||
with patch.dict("sys.modules", {"litellm": MagicMock()}):
|
||||
with patch(
|
||||
"hindsight_api.engine.providers.litellm_router_llm.LiteLLMRouterLLM.__init__",
|
||||
return_value=None,
|
||||
) as mock_init:
|
||||
impl = create_llm_provider(
|
||||
provider="litellmrouter",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="unused",
|
||||
reasoning_effort="low",
|
||||
litellmrouter_config=two_step_config,
|
||||
)
|
||||
assert isinstance(impl, LiteLLMRouterLLM)
|
||||
_, kwargs = mock_init.call_args
|
||||
assert kwargs["config"] == two_step_config
|
||||
|
||||
|
||||
# --- Router-backed call paths ------------------------------------------------
|
||||
|
||||
|
||||
def _make_router_provider(config: dict[str, Any], mock_router: Any) -> LiteLLMRouterLLM:
|
||||
"""Construct a LiteLLMRouterLLM with the inner Router replaced by a mock."""
|
||||
fake_litellm = MagicMock()
|
||||
fake_litellm.Router = MagicMock(return_value=mock_router)
|
||||
with patch.dict("sys.modules", {"litellm": fake_litellm}):
|
||||
# Bypass the heavy ctor chain by injecting state directly.
|
||||
provider = LiteLLMRouterLLM.__new__(LiteLLMRouterLLM)
|
||||
provider.provider = "litellmrouter"
|
||||
provider.api_key = ""
|
||||
provider.base_url = ""
|
||||
provider.model = "unused"
|
||||
provider.reasoning_effort = "low"
|
||||
provider.timeout = 300.0
|
||||
provider.config = config
|
||||
provider._litellm = fake_litellm
|
||||
provider._router = mock_router
|
||||
provider._router_output_cap = None # tests that exercise the cap override this directly
|
||||
return provider
|
||||
|
||||
|
||||
class TestRouterCall:
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_text_call_targets_default_entrypoint(self, two_step_config, mock_router_response):
|
||||
mock_router = MagicMock()
|
||||
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
|
||||
provider = _make_router_provider(two_step_config, mock_router)
|
||||
|
||||
result = await provider.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_completion_tokens=50,
|
||||
max_retries=0,
|
||||
)
|
||||
assert result == "ok"
|
||||
# Hindsight always issues against model_name="default"; Router handles fallback,
|
||||
# load-balancing, and routing strategy from there.
|
||||
kwargs = mock_router.acompletion.await_args.kwargs
|
||||
assert kwargs["model"] == "default"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_output(self, two_step_config):
|
||||
class MySchema(BaseModel):
|
||||
answer: str
|
||||
|
||||
response = MagicMock()
|
||||
choice = MagicMock()
|
||||
choice.message.content = '{"answer": "42"}'
|
||||
choice.message.tool_calls = None
|
||||
choice.finish_reason = "stop"
|
||||
response.choices = [choice]
|
||||
response.usage.prompt_tokens = 5
|
||||
response.usage.completion_tokens = 5
|
||||
response._hidden_params = {"model": "openai/gpt-4o-mini"}
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.acompletion = AsyncMock(return_value=response)
|
||||
provider = _make_router_provider(two_step_config, mock_router)
|
||||
|
||||
result = await provider.call(
|
||||
messages=[{"role": "user", "content": "q"}],
|
||||
response_format=MySchema,
|
||||
max_retries=0,
|
||||
)
|
||||
assert isinstance(result, MySchema)
|
||||
assert result.answer == "42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_transient_then_success(self, two_step_config, mock_router_response):
|
||||
mock_router = MagicMock()
|
||||
# First call raises a 503-style error, second call returns ok.
|
||||
mock_router.acompletion = AsyncMock(side_effect=[Exception("503 Service Unavailable"), mock_router_response])
|
||||
provider = _make_router_provider(two_step_config, mock_router)
|
||||
|
||||
result = await provider.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_retries=2,
|
||||
initial_backoff=0.0,
|
||||
max_backoff=0.0,
|
||||
)
|
||||
assert result == "ok"
|
||||
assert mock_router.acompletion.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_error_does_not_retry(self, two_step_config):
|
||||
mock_router = MagicMock()
|
||||
mock_router.acompletion = AsyncMock(side_effect=Exception("401 Unauthorized: bad key"))
|
||||
provider = _make_router_provider(two_step_config, mock_router)
|
||||
|
||||
with pytest.raises(Exception, match="401"):
|
||||
await provider.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_retries=5,
|
||||
initial_backoff=0.0,
|
||||
)
|
||||
assert mock_router.acompletion.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caps_max_completion_tokens_to_litellm_registry(self, two_step_config, mock_router_response):
|
||||
"""Cap max_completion_tokens to the most conservative deployment limit.
|
||||
|
||||
Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
|
||||
high-capacity models. When a configured deployment has a smaller cap
|
||||
(gpt-4.1-nano = 32768), the call would otherwise be rejected — apply
|
||||
the cap silently using LiteLLM's per-model registry.
|
||||
"""
|
||||
mock_router = MagicMock()
|
||||
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
|
||||
provider = _make_router_provider(two_step_config, mock_router)
|
||||
provider._router_output_cap = 32768 # what _compute_router_output_cap would yield
|
||||
|
||||
await provider.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_completion_tokens=64000, # over the cap
|
||||
max_retries=0,
|
||||
)
|
||||
kwargs = mock_router.acompletion.await_args.kwargs
|
||||
assert kwargs["max_completion_tokens"] == 32768
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_cap_when_litellm_registry_has_no_data(self, two_step_config, mock_router_response):
|
||||
"""If LiteLLM doesn't know any of the deployment models, pass the requested value through."""
|
||||
mock_router = MagicMock()
|
||||
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
|
||||
provider = _make_router_provider(two_step_config, mock_router)
|
||||
provider._router_output_cap = None
|
||||
|
||||
await provider.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_completion_tokens=64000,
|
||||
max_retries=0,
|
||||
)
|
||||
kwargs = mock_router.acompletion.await_args.kwargs
|
||||
assert kwargs["max_completion_tokens"] == 64000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_with_tools(self, two_step_config):
|
||||
response = MagicMock()
|
||||
choice = MagicMock()
|
||||
choice.message.content = None
|
||||
tool_call = MagicMock()
|
||||
tool_call.id = "call_1"
|
||||
tool_call.function.name = "lookup"
|
||||
tool_call.function.arguments = '{"q": "x"}'
|
||||
choice.message.tool_calls = [tool_call]
|
||||
choice.finish_reason = "tool_calls"
|
||||
response.choices = [choice]
|
||||
response.usage.prompt_tokens = 5
|
||||
response.usage.completion_tokens = 2
|
||||
response._hidden_params = {"model": "openai/gpt-4o-mini"}
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.acompletion = AsyncMock(return_value=response)
|
||||
provider = _make_router_provider(two_step_config, mock_router)
|
||||
|
||||
result = await provider.call_with_tools(
|
||||
messages=[{"role": "user", "content": "use tool"}],
|
||||
tools=[{"type": "function", "function": {"name": "lookup", "parameters": {}}}],
|
||||
max_retries=0,
|
||||
)
|
||||
assert result.content is None
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "lookup"
|
||||
assert result.tool_calls[0].arguments == {"q": "x"}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Regression tests for reflect tool helpers."""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.reflect.tools import _document_metadata_from_retain_params, tool_expand
|
||||
|
||||
|
||||
class _FakeReflectConnection:
|
||||
"""Tiny asyncpg-like connection for tool_expand query behavior."""
|
||||
|
||||
def __init__(self, bank_id: str, memory_id: uuid.UUID, document_id: str, chunk_id: str | None) -> None:
|
||||
self.bank_id = bank_id
|
||||
self.memory_id = memory_id
|
||||
self.document_id = document_id
|
||||
self.chunk_id = chunk_id
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized_query = re.sub(r"\s+", " ", query).strip()
|
||||
|
||||
if "FROM public.memory_units" in normalized_query:
|
||||
return [
|
||||
{
|
||||
"id": self.memory_id,
|
||||
"text": "The user prefers test-first bug fixes.",
|
||||
"chunk_id": self.chunk_id,
|
||||
"document_id": self.document_id,
|
||||
"fact_type": "experience",
|
||||
"context": "preference",
|
||||
}
|
||||
]
|
||||
|
||||
if "FROM public.chunks" in normalized_query:
|
||||
if self.chunk_id is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"chunk_id": self.chunk_id,
|
||||
"chunk_text": "The user prefers test-first bug fixes.",
|
||||
"chunk_index": 0,
|
||||
"document_id": self.document_id,
|
||||
}
|
||||
]
|
||||
|
||||
if "FROM public.documents" in normalized_query:
|
||||
select_clause = normalized_query.split(" FROM ", 1)[0]
|
||||
assert " metadata," not in f" {select_clause},", (
|
||||
"tool_expand must not query documents.metadata; that column was removed and "
|
||||
"document metadata now lives in retain_params.metadata"
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": self.document_id,
|
||||
"original_text": "The user prefers test-first bug fixes.",
|
||||
"retain_params": {"metadata": {"source": "regression-test"}},
|
||||
}
|
||||
]
|
||||
|
||||
raise AssertionError(f"Unexpected query: {normalized_query}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_expand_document_depth_reads_metadata_from_retain_params() -> None:
|
||||
"""Document expansion must work after documents.metadata has been dropped."""
|
||||
bank_id = "test-reflect-expand-retain-params-metadata"
|
||||
memory_id = uuid.uuid4()
|
||||
document_id = "doc-reflect-expand"
|
||||
chunk_id = "chunk-reflect-expand"
|
||||
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id)
|
||||
|
||||
result = await tool_expand(
|
||||
conn=conn,
|
||||
bank_id=bank_id,
|
||||
memory_ids=[str(memory_id)],
|
||||
depth="document",
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
document = result["results"][0]["document"]
|
||||
assert document["metadata"] == {"source": "regression-test"}
|
||||
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_expand_document_depth_without_chunk_reads_metadata_from_retain_params() -> None:
|
||||
"""Direct document expansion follows the same metadata source contract."""
|
||||
bank_id = "test-reflect-expand-direct-retain-params-metadata"
|
||||
memory_id = uuid.uuid4()
|
||||
document_id = "doc-reflect-expand-direct"
|
||||
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id=None)
|
||||
|
||||
result = await tool_expand(
|
||||
conn=conn,
|
||||
bank_id=bank_id,
|
||||
memory_ids=[str(memory_id)],
|
||||
depth="document",
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
document = result["results"][0]["document"]
|
||||
assert document["metadata"] == {"source": "regression-test"}
|
||||
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
|
||||
|
||||
|
||||
def test_document_metadata_from_retain_params_accepts_json_strings() -> None:
|
||||
"""asyncpg JSONB codecs may return retain_params as a dict or JSON string."""
|
||||
retain_params = '{"metadata": {"source": "json-string"}}'
|
||||
|
||||
assert _document_metadata_from_retain_params(retain_params) == {"source": "json-string"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"retain_params",
|
||||
[None, [], "not json", {"metadata": ["not", "a", "dict"]}],
|
||||
)
|
||||
def test_document_metadata_from_retain_params_ignores_invalid_values(retain_params) -> None:
|
||||
"""Malformed retain_params should not break reflect expansion."""
|
||||
assert _document_metadata_from_retain_params(retain_params) is None
|
||||
@@ -2394,6 +2394,75 @@ async def test_pending_breakdown_explains_unclaimable_rows(pool, backend, clean_
|
||||
assert buckets["consolidation"]["claimable"] >= 1
|
||||
|
||||
|
||||
class TestSummariseChildErrorMessages:
|
||||
"""Pure unit tests for the _summarise_child_error_messages helper.
|
||||
|
||||
The helper picks a representative error message for a parent whose
|
||||
children failed. The integration tests above exercise the full path
|
||||
through _mark_failed; these tests focus on the choice itself.
|
||||
"""
|
||||
|
||||
def _sib(self, status: str, error_message: str | None = None) -> dict:
|
||||
return {"status": status, "error_message": error_message}
|
||||
|
||||
def test_all_failed_with_same_message_inherits_that_message(self):
|
||||
from hindsight_api.worker.poller import _summarise_child_error_messages
|
||||
|
||||
siblings = [
|
||||
self._sib("failed", "boom"),
|
||||
self._sib("failed", "boom"),
|
||||
self._sib("failed", "boom"),
|
||||
]
|
||||
assert _summarise_child_error_messages(siblings) == "boom"
|
||||
|
||||
def test_mixed_failed_messages_picks_most_common(self):
|
||||
from hindsight_api.worker.poller import _summarise_child_error_messages
|
||||
|
||||
siblings = [
|
||||
self._sib("failed", "common cause"),
|
||||
self._sib("failed", "common cause"),
|
||||
self._sib("failed", "rare cause"),
|
||||
]
|
||||
assert _summarise_child_error_messages(siblings) == "common cause"
|
||||
|
||||
def test_completed_siblings_ignored(self):
|
||||
from hindsight_api.worker.poller import _summarise_child_error_messages
|
||||
|
||||
siblings = [
|
||||
self._sib("completed", None),
|
||||
self._sib("completed", None),
|
||||
self._sib("failed", "the one real failure"),
|
||||
]
|
||||
assert _summarise_child_error_messages(siblings) == "the one real failure"
|
||||
|
||||
def test_no_failed_siblings_falls_back_to_generic(self):
|
||||
from hindsight_api.worker.poller import _summarise_child_error_messages
|
||||
|
||||
siblings = [
|
||||
self._sib("completed", None),
|
||||
self._sib("completed", None),
|
||||
]
|
||||
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
|
||||
|
||||
def test_failed_siblings_with_no_error_message_falls_back_to_generic(self):
|
||||
from hindsight_api.worker.poller import _summarise_child_error_messages
|
||||
|
||||
siblings = [
|
||||
self._sib("failed", None),
|
||||
self._sib("failed", ""),
|
||||
]
|
||||
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
|
||||
|
||||
def test_whitespace_only_messages_treated_as_empty(self):
|
||||
from hindsight_api.worker.poller import _summarise_child_error_messages
|
||||
|
||||
siblings = [
|
||||
self._sib("failed", " "),
|
||||
self._sib("failed", "actual error"),
|
||||
]
|
||||
assert _summarise_child_error_messages(siblings) == "actual error"
|
||||
|
||||
|
||||
class TestMarkFailedParentPropagation:
|
||||
"""Tests for _mark_failed parent propagation in WorkerPoller.
|
||||
|
||||
@@ -2473,10 +2542,21 @@ class TestMarkFailedParentPropagation:
|
||||
assert "DB constraint violation" in child2_row["error_message"]
|
||||
|
||||
# parent must now be failed (all siblings done, at least one failed)
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
parent_row = await pool.fetchrow(
|
||||
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
|
||||
parent_id,
|
||||
)
|
||||
assert parent_row["status"] == "failed", (
|
||||
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
|
||||
)
|
||||
# Parent error_message must propagate the child's actual error reason,
|
||||
# not the legacy generic "One or more sub-batches failed". Without this,
|
||||
# downstream filters that classify failures by error_message lose all
|
||||
# signal once a batch has children.
|
||||
assert "DB constraint violation" in (parent_row["error_message"] or ""), (
|
||||
f"Parent error_message should inherit child's reason, "
|
||||
f"got: {parent_row['error_message']!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, backend, clean_operations):
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.6.0"
|
||||
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.0",
|
||||
"hindsight-api-slim[all]==0.6.2",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.6.2"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -1262,8 +1262,8 @@ impl ApiClient {
|
||||
|
||||
// Re-export types from the generated client for use in commands
|
||||
pub use types::{
|
||||
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
|
||||
ReflectResponse, RetainRequest,
|
||||
BankProfileResponse, MemoryItem, MemoryItemTimestamp, RecallRequest, RecallResponse,
|
||||
RecallResult, ReflectRequest, ReflectResponse, RetainRequest,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
|
||||
use crate::api::{
|
||||
ApiClient, MemoryItem, MemoryItemTimestamp, RecallRequest, ReflectRequest, RetainRequest,
|
||||
};
|
||||
use crate::config;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
@@ -438,6 +440,7 @@ pub fn retain(
|
||||
content: String,
|
||||
doc_id: Option<String>,
|
||||
context: Option<String>,
|
||||
timestamp: Option<String>,
|
||||
r#async: bool,
|
||||
document_tags: Option<Vec<String>>,
|
||||
verbose: bool,
|
||||
@@ -451,11 +454,20 @@ pub fn retain(
|
||||
None
|
||||
};
|
||||
|
||||
// MemoryItem.timestamp is a progenitor anyOf enum; round-trip through JSON to pick the matching variant.
|
||||
let timestamp = match timestamp {
|
||||
Some(s) => Some(
|
||||
serde_json::from_value::<MemoryItemTimestamp>(serde_json::Value::String(s.clone()))
|
||||
.with_context(|| format!("invalid --timestamp value: {:?}", s))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let item = MemoryItem {
|
||||
content: content.clone(),
|
||||
context,
|
||||
metadata: None,
|
||||
timestamp: None,
|
||||
timestamp,
|
||||
document_id: Some(doc_id.clone()),
|
||||
entities: None,
|
||||
tags: None,
|
||||
|
||||
@@ -591,6 +591,12 @@ enum MemoryCommands {
|
||||
#[arg(short = 'c', long)]
|
||||
context: Option<String>,
|
||||
|
||||
/// When the content occurred (ISO 8601 datetime, e.g. 2024-01-15T10:30:00Z
|
||||
/// or 2024-01-15). Pass "unset" to store without a timestamp.
|
||||
/// Omit to default to now.
|
||||
#[arg(short = 't', long)]
|
||||
timestamp: Option<String>,
|
||||
|
||||
/// Queue for background processing
|
||||
#[arg(long)]
|
||||
r#async: bool,
|
||||
@@ -1434,6 +1440,7 @@ fn run() -> Result<()> {
|
||||
content,
|
||||
doc_id,
|
||||
context,
|
||||
timestamp,
|
||||
r#async,
|
||||
document_tags,
|
||||
} => commands::memory::retain(
|
||||
@@ -1442,6 +1449,7 @@ fn run() -> Result<()> {
|
||||
content,
|
||||
doc_id,
|
||||
context,
|
||||
timestamp,
|
||||
r#async,
|
||||
document_tags,
|
||||
verbose,
|
||||
|
||||
@@ -91,6 +91,25 @@ fn test_ui_command_with_config() {
|
||||
std::fs::remove_dir_all(&temp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_retain_exposes_timestamp_flag() {
|
||||
// Regression: `hindsight memory retain` historically had no way to set the
|
||||
// memory's event date even though the SDKs do. The flag must appear in
|
||||
// --help so users (and docs) can discover it.
|
||||
let output = Command::new("cargo")
|
||||
.args(["run", "--", "memory", "retain", "--help"])
|
||||
.output()
|
||||
.expect("Failed to execute command");
|
||||
|
||||
assert!(output.status.success(), "retain --help failed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("--timestamp") && stdout.contains("-t"),
|
||||
"expected --timestamp/-t flag in retain --help, got: {}",
|
||||
stdout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_command() {
|
||||
// Test that configure command creates/updates config
|
||||
|
||||
@@ -7,7 +7,7 @@ info:
|
||||
name: Apache 2.0
|
||||
url: https://www.apache.org/licenses/LICENSE-2.0.html
|
||||
title: Hindsight HTTP API
|
||||
version: 0.6.0
|
||||
version: 0.6.2
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.6.2
|
||||
// In most cases there should be only one, shared, APIClient.
|
||||
type APIClient struct {
|
||||
cfg *Configuration
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.6.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
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.0
|
||||
API version: 0.6.2
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user