Skip to content

Commit e5b46fb

Browse files
authored
Merge pull request #358 from aaronsb/feature/concept-evidence-required
feat(graph): require evidence on manual concept creation
2 parents 1ad4005 + a08789f commit e5b46fb

28 files changed

Lines changed: 2277 additions & 88 deletions

api/app/lib/ai_providers.py

Lines changed: 522 additions & 10 deletions
Large diffs are not rendered by default.

api/app/lib/model_catalog.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""
2+
Provider model catalog management (ADR-800).
3+
4+
Handles fetching, upserting, and querying the provider_model_catalog table.
5+
"""
6+
7+
import json
8+
import logging
9+
from datetime import datetime, timezone
10+
from typing import Any, Dict, List, Optional
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def upsert_catalog_entries(conn, entries: List[Dict[str, Any]]) -> int:
16+
"""
17+
Upsert model catalog entries into provider_model_catalog.
18+
19+
Args:
20+
conn: psycopg2 connection
21+
entries: List of dicts with catalog column values from fetch_model_catalog()
22+
23+
Returns:
24+
Number of rows upserted
25+
"""
26+
if not entries:
27+
return 0
28+
29+
now = datetime.now(timezone.utc)
30+
count = 0
31+
32+
with conn.cursor() as cur:
33+
for entry in entries:
34+
raw = entry.get("raw_metadata")
35+
raw_json = json.dumps(raw) if raw is not None else None
36+
37+
cur.execute(
38+
"""INSERT INTO kg_api.provider_model_catalog
39+
(provider, model_id, display_name, category, context_length,
40+
max_completion_tokens, supports_vision, supports_json_mode,
41+
supports_tool_use, supports_streaming,
42+
price_prompt_per_m, price_completion_per_m, price_cache_read_per_m,
43+
upstream_provider, raw_metadata, fetched_at, updated_at)
44+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
45+
ON CONFLICT (provider, model_id, category) DO UPDATE SET
46+
display_name = EXCLUDED.display_name,
47+
context_length = COALESCE(EXCLUDED.context_length, kg_api.provider_model_catalog.context_length),
48+
max_completion_tokens = COALESCE(EXCLUDED.max_completion_tokens, kg_api.provider_model_catalog.max_completion_tokens),
49+
supports_vision = EXCLUDED.supports_vision,
50+
supports_json_mode = EXCLUDED.supports_json_mode,
51+
supports_tool_use = EXCLUDED.supports_tool_use,
52+
supports_streaming = EXCLUDED.supports_streaming,
53+
price_prompt_per_m = COALESCE(EXCLUDED.price_prompt_per_m, kg_api.provider_model_catalog.price_prompt_per_m),
54+
price_completion_per_m = COALESCE(EXCLUDED.price_completion_per_m, kg_api.provider_model_catalog.price_completion_per_m),
55+
price_cache_read_per_m = COALESCE(EXCLUDED.price_cache_read_per_m, kg_api.provider_model_catalog.price_cache_read_per_m),
56+
upstream_provider = EXCLUDED.upstream_provider,
57+
raw_metadata = EXCLUDED.raw_metadata,
58+
fetched_at = EXCLUDED.fetched_at,
59+
updated_at = EXCLUDED.updated_at
60+
""",
61+
(
62+
entry["provider"],
63+
entry["model_id"],
64+
entry.get("display_name"),
65+
entry["category"],
66+
entry.get("context_length"),
67+
entry.get("max_completion_tokens"),
68+
entry.get("supports_vision", False),
69+
entry.get("supports_json_mode", False),
70+
entry.get("supports_tool_use", False),
71+
entry.get("supports_streaming", True),
72+
entry.get("price_prompt_per_m"),
73+
entry.get("price_completion_per_m"),
74+
entry.get("price_cache_read_per_m"),
75+
entry.get("upstream_provider"),
76+
raw_json,
77+
now,
78+
now,
79+
),
80+
)
81+
count += 1
82+
83+
conn.commit()
84+
logger.info(f"Upserted {count} catalog entries")
85+
return count
86+
87+
88+
def list_catalog(
89+
conn,
90+
provider: Optional[str] = None,
91+
category: Optional[str] = None,
92+
enabled_only: bool = False,
93+
) -> List[Dict[str, Any]]:
94+
"""
95+
Query provider_model_catalog with optional filters.
96+
97+
Returns list of dicts with all catalog columns.
98+
"""
99+
conditions = []
100+
params = []
101+
102+
if provider:
103+
conditions.append("provider = %s")
104+
params.append(provider)
105+
if category:
106+
conditions.append("category = %s")
107+
params.append(category)
108+
if enabled_only:
109+
conditions.append("enabled = TRUE")
110+
111+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
112+
113+
with conn.cursor() as cur:
114+
cur.execute(
115+
f"""SELECT id, provider, model_id, display_name, category,
116+
context_length, max_completion_tokens,
117+
supports_vision, supports_json_mode, supports_tool_use,
118+
supports_streaming,
119+
price_prompt_per_m, price_completion_per_m, price_cache_read_per_m,
120+
enabled, is_default, sort_order,
121+
upstream_provider, fetched_at, created_at, updated_at
122+
FROM kg_api.provider_model_catalog
123+
{where}
124+
ORDER BY provider, sort_order, model_id""",
125+
params,
126+
)
127+
columns = [desc[0] for desc in cur.description]
128+
return [dict(zip(columns, row)) for row in cur.fetchall()]
129+
130+
131+
def set_model_enabled(conn, catalog_id: int, enabled: bool) -> bool:
132+
"""Enable or disable a model in the catalog."""
133+
with conn.cursor() as cur:
134+
cur.execute(
135+
"""UPDATE kg_api.provider_model_catalog
136+
SET enabled = %s, updated_at = NOW()
137+
WHERE id = %s""",
138+
(enabled, catalog_id),
139+
)
140+
conn.commit()
141+
return cur.rowcount > 0
142+
143+
144+
def set_model_default(conn, catalog_id: int) -> bool:
145+
"""
146+
Set a model as the default for its provider+category.
147+
148+
Clears existing default for that provider+category first.
149+
"""
150+
with conn.cursor() as cur:
151+
# Get the provider and category for this model
152+
cur.execute(
153+
"SELECT provider, category FROM kg_api.provider_model_catalog WHERE id = %s",
154+
(catalog_id,),
155+
)
156+
row = cur.fetchone()
157+
if not row:
158+
return False
159+
160+
provider, category = row
161+
162+
# Clear existing default
163+
cur.execute(
164+
"""UPDATE kg_api.provider_model_catalog
165+
SET is_default = FALSE, updated_at = NOW()
166+
WHERE provider = %s AND category = %s AND is_default = TRUE""",
167+
(provider, category),
168+
)
169+
170+
# Set new default (also ensures enabled)
171+
cur.execute(
172+
"""UPDATE kg_api.provider_model_catalog
173+
SET is_default = TRUE, enabled = TRUE, updated_at = NOW()
174+
WHERE id = %s""",
175+
(catalog_id,),
176+
)
177+
conn.commit()
178+
return True
179+
180+
181+
def update_model_pricing(
182+
conn,
183+
catalog_id: int,
184+
price_prompt_per_m: Optional[float] = None,
185+
price_completion_per_m: Optional[float] = None,
186+
) -> bool:
187+
"""Manually override pricing for a catalog entry."""
188+
updates = []
189+
params = []
190+
191+
if price_prompt_per_m is not None:
192+
updates.append("price_prompt_per_m = %s")
193+
params.append(price_prompt_per_m)
194+
if price_completion_per_m is not None:
195+
updates.append("price_completion_per_m = %s")
196+
params.append(price_completion_per_m)
197+
198+
if not updates:
199+
return False
200+
201+
updates.append("updated_at = NOW()")
202+
params.append(catalog_id)
203+
204+
with conn.cursor() as cur:
205+
cur.execute(
206+
f"""UPDATE kg_api.provider_model_catalog
207+
SET {', '.join(updates)}
208+
WHERE id = %s""",
209+
params,
210+
)
211+
conn.commit()
212+
return cur.rowcount > 0
213+
214+
215+
def get_model_pricing(conn, provider: str, model_id: str) -> Optional[Dict[str, Any]]:
216+
"""
217+
Look up pricing for a specific model from the catalog.
218+
219+
Returns dict with price_prompt_per_m and price_completion_per_m, or None.
220+
"""
221+
with conn.cursor() as cur:
222+
cur.execute(
223+
"""SELECT price_prompt_per_m, price_completion_per_m, price_cache_read_per_m
224+
FROM kg_api.provider_model_catalog
225+
WHERE provider = %s AND model_id = %s AND enabled = TRUE
226+
LIMIT 1""",
227+
(provider, model_id),
228+
)
229+
row = cur.fetchone()
230+
if row:
231+
return {
232+
"price_prompt_per_m": float(row[0]) if row[0] is not None else None,
233+
"price_completion_per_m": float(row[1]) if row[1] is not None else None,
234+
"price_cache_read_per_m": float(row[2]) if row[2] is not None else None,
235+
}
236+
return None

api/app/lib/rate_limiter.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ def get_provider_concurrency_limit(provider_name: str) -> int:
4343
- ollama: 1 (single GPU/CPU bottleneck)
4444
- anthropic: 4 (moderate API rate limits)
4545
- openai: 8 (higher API rate limits)
46+
- openrouter: 8 (cloud API, similar to OpenAI)
4647
- mock: 100 (no real limits for testing)
4748
4849
Args:
49-
provider_name: Provider name ('openai', 'anthropic', 'ollama', 'mock')
50+
provider_name: Provider name ('openai', 'anthropic', 'ollama', 'openrouter', 'mock')
5051
5152
Returns:
5253
Maximum concurrent requests allowed for this provider
@@ -58,6 +59,7 @@ def get_provider_concurrency_limit(provider_name: str) -> int:
5859
'ollama': 1, # Local GPU/CPU - serialize to avoid thrashing
5960
'anthropic': 4, # Moderate API rate limits
6061
'openai': 8, # Higher API rate limits
62+
'openrouter': 8, # Cloud API proxy - similar to OpenAI
6163
'mock': 100, # Testing - no real limits
6264
}
6365

@@ -137,10 +139,11 @@ def get_provider_max_retries(provider_name: str) -> int:
137139
- ollama: 3 (local, fewer retries needed)
138140
- anthropic: 8 (cloud API, more resilience)
139141
- openai: 8 (cloud API, more resilience)
142+
- openrouter: 8 (cloud API proxy, more resilience)
140143
- mock: 0 (testing, no retries)
141144
142145
Args:
143-
provider_name: Provider name ('openai', 'anthropic', 'ollama', 'mock')
146+
provider_name: Provider name ('openai', 'anthropic', 'ollama', 'openrouter', 'mock')
144147
145148
Returns:
146149
Maximum retry attempts for rate-limited requests
@@ -152,6 +155,7 @@ def get_provider_max_retries(provider_name: str) -> int:
152155
'ollama': 3, # Local - fewer retries needed
153156
'anthropic': 8, # Cloud API - more resilience
154157
'openai': 8, # Cloud API - more resilience
158+
'openrouter': 8, # Cloud API proxy - more resilience
155159
'mock': 0, # Testing - no retries
156160
}
157161

api/app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from .services.worker_registry import register_all_workers, get_all_job_types, validate_lane_uniqueness
3232
from .services.lane_manager import LaneManager
3333
from .launchers import CategoryRefreshLauncher, VocabConsolidationLauncher, EpistemicRemeasurementLauncher, ProjectionLauncher, ArtifactCleanupLauncher, AnnealingLauncher
34-
from .routes import ingest, ingest_image, jobs, queries, database, ontology, admin, auth, rbac, vocabulary, vocabulary_config, embedding, extraction, oauth, sources, projection, artifacts, grants, query_definitions, documents, concepts, edges, graph, storage_admin, programs, admin_workers
34+
from .routes import ingest, ingest_image, jobs, queries, database, ontology, admin, auth, rbac, vocabulary, vocabulary_config, embedding, extraction, oauth, sources, projection, artifacts, grants, query_definitions, documents, concepts, edges, graph, storage_admin, programs, admin_workers, models
3535
from .services.embedding_worker import get_embedding_worker
3636
from .lib.age_client import AGEClient
3737
from .lib.ai_providers import get_provider
@@ -434,6 +434,7 @@ async def shutdown_event():
434434
app.include_router(graph.router) # ADR-089: Batch graph operations
435435
app.include_router(storage_admin.router) # Storage diagnostics
436436
app.include_router(admin_workers.router) # ADR-100: Worker lane management
437+
app.include_router(models.admin_router) # ADR-800: Model catalog management
437438

438439

439440
# Root endpoint

api/app/models/concepts.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ class ConceptCreate(BaseModel):
7474
CreationMethod.API,
7575
description="How this concept is being created"
7676
)
77+
evidence_text: Optional[str] = Field(
78+
None,
79+
description="Evidence/rationale for the concept (required for manual creation via API/CLI/MCP)",
80+
min_length=10,
81+
max_length=2000
82+
)
7783

7884
class Config:
7985
json_schema_extra = {
@@ -88,6 +94,26 @@ class Config:
8894
}
8995

9096

97+
class EvidenceCreate(BaseModel):
98+
"""Request to add evidence to an existing concept."""
99+
100+
evidence_text: str = Field(
101+
...,
102+
description="Evidence/rationale text supporting the concept",
103+
min_length=10,
104+
max_length=2000
105+
)
106+
107+
108+
class EvidenceResponse(BaseModel):
109+
"""Response from adding evidence to a concept."""
110+
111+
concept_id: str
112+
instance_id: str
113+
source_id: str
114+
evidence_text: str
115+
116+
91117
class ConceptUpdate(BaseModel):
92118
"""Request to update an existing concept (partial update)."""
93119

0 commit comments

Comments
 (0)