From f5fa2102af905b68daf245b174b1017e74a86abc Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 7 Sep 2026 17:24:52 -0700 Subject: [PATCH 01/48] feat: add generic OpenAI-compatible DeepSeek V4 routing --- .github/workflows/deploy.yml | 29 +- .gitignore | 1 + CLAUDE.md | 8 +- DEPLOYMENT.md | 12 +- README.md | 18 +- a0/adapters/__init__.py | 12 +- a0/adapters/openai_adapter.py | 2 - a0/adapters/openai_compatible_adapter.py | 140 ++++++ a0/provider_registry.py | 78 ++++ a0/router.py | 17 +- a0_msdmd.ts | 228 +++++++++- capacitor.config.ts | 2 + cloudbuild.yaml | 25 +- docs/ARCHITECTURE.md | 10 +- python/config/pricing.json | 32 +- python/config/providers.json | 208 ++++++++- python/main.py | 4 +- python/services/energy_registry.py | 16 +- python/services/inference.py | 46 +- python/services/model_catalog.py | 18 +- python/services/providers/__init__.py | 4 +- python/services/providers/_resolver.py | 17 +- .../providers/openai_compatible_provider.py | 411 ++++++++++++++++++ python/services/providers/openai_provider.py | 210 +-------- python/services/providers/xai_provider.py | 2 +- .../tests/test_openai_compatible_contracts.py | 94 ++++ repl_nix_workspace.egg-info/SOURCES.txt | 3 + suggest.md | 23 +- tests/test_a0_openai_compatible_adapter.py | 116 +++++ tests/test_openai_compatible_provider.py | 252 +++++++++++ 30 files changed, 1728 insertions(+), 310 deletions(-) delete mode 100644 a0/adapters/openai_adapter.py create mode 100644 a0/adapters/openai_compatible_adapter.py create mode 100644 a0/provider_registry.py create mode 100644 python/services/providers/openai_compatible_provider.py create mode 100644 python/tests/test_openai_compatible_contracts.py create mode 100644 tests/test_a0_openai_compatible_adapter.py create mode 100644 tests/test_openai_compatible_provider.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69a0210ab..6c8ee6cdd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,31 @@ env: REGISTRY: us-central1-docker.pkg.dev jobs: + provider-contracts: + name: Provider adapter contracts + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.11.18" + enable-cache: true + + - name: Install Python dependencies + run: uv sync --frozen + + - name: Run provider contracts + run: uv run pytest -q tests/test_openai_compatible_provider.py tests/test_a0_openai_compatible_adapter.py tests/test_a0_package.py python/tests/test_openai_compatible_contracts.py + check-console-tabs: name: Console tab regression guard runs-on: ubuntu-latest @@ -125,7 +150,7 @@ jobs: deploy: name: Build, push, deploy - needs: [check-console-tabs, deployment-readiness] + needs: [provider-contracts, check-console-tabs, deployment-readiness] if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.GCP_DEPLOY_ENABLED == 'true' runs-on: ubuntu-latest permissions: @@ -170,4 +195,4 @@ jobs: --max-instances=10 \ --memory=512Mi \ --cpu=1 \ - --set-secrets="DATABASE_URL=a0p-database-url:latest,SESSION_SECRET=a0p-session-secret:latest,XAI_API_KEY=a0p-xai-api-key:latest,STRIPE_SECRET_KEY=a0p-stripe-secret-key:latest,STRIPE_WEBHOOK_SECRET=a0p-stripe-webhook-secret:latest" + --set-secrets="DATABASE_URL=a0p-database-url:latest,SESSION_SECRET=a0p-session-secret:latest,XAI_API_KEY=a0p-xai-api-key:latest,DEEPSEEK_API_KEY=a0p-deepseek-api-key:latest,STRIPE_SECRET_KEY=a0p-stripe-secret-key:latest,STRIPE_WEBHOOK_SECRET=a0p-stripe-webhook-secret:latest" diff --git a/.gitignore b/.gitignore index 257931c52..1a4a425a4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ playwright-report/ # Local secrets .env .venv/ +.skill-lib/ .state/ android/keystore/ android/keystore.properties diff --git a/CLAUDE.md b/CLAUDE.md index eafb11e5e..ce5c62ba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,9 @@ Naming convention: `{name}.py` = self-contained module; `{name}_api.py` = thin d ### Key Python Services & Engines -- `python/services/inference.py` — Orchestrates LLM calls across registered energy providers (Grok / Gemini / Claude / OpenAI-style); resolves role, normalizes reasoning effort, injects tier-specific `prompt_context`. +- `python/services/inference.py` — Orchestrates LLM calls across registered energy providers (Grok / Gemini / Claude / OpenAI-compatible); resolves role, normalizes reasoning effort, injects tier-specific `prompt_context`. +- `python/services/providers/openai_compatible_provider.py` — Generic Responses/Chat Completions transport; endpoints, credential env names, models, and provider quirks stay in `python/config/providers.json`. +- `a0/provider_registry.py` + `a0/adapters/openai_compatible_adapter.py` — Standalone/Termux provider selection from that same registry; `A0_PROVIDER` is explicit and fail-closed. - `python/services/heartbeat.py` — Periodic tick: audit snapshots, memory checkpoints, PCNA propagation, sub-agent cleanup. - `python/services/tool_executor.py` — Tool invocation with approval gates. - `python/engine/ptcna_state.py` — durable Platonic-Agent adapter over the exactly pinned producer-owned PTCNA pipeline and UCNS receipt. @@ -171,7 +173,7 @@ Auth is handled entirely by Express. Tiers (Free → Seeker → Operator → Pat | Workflow | Trigger | Does | |----------|---------|------| -| `.github/workflows/deploy.yml` | push/PR to `main` | Boots Postgres + Python backend, runs console-tab guard; on push to `main`, builds Docker image and deploys to Cloud Run (`a0p`, us-central1) | +| `.github/workflows/deploy.yml` | push/PR to `main` | Runs provider contracts, boots Postgres + Python for the console-tab guard; on push to `main`, builds and deploys to Cloud Run (`a0p`, us-central1) | | `.github/workflows/clean-build-check.yml` | push/PR to `main` | Builds with `REPL_ID` unset and fails if any `@replit` reference leaks into the client bundle | --- @@ -185,6 +187,8 @@ SESSION_SECRET # Express session encryption (no fallback in prod) INTERNAL_API_SECRET # Express→Python shared secret (start-dev.sh generates a per-run value) DATABASE_URL # PostgreSQL connection string XAI_API_KEY # Grok energy provider +DEEPSEEK_API_KEY # DeepSeek V4 Flash/Pro; also used by standalone a0 +A0_PROVIDER # Optional standalone provider id (e.g. deepseek-pro) STRIPE_SECRET_KEY # Stripe billing STRIPE_WEBHOOK_SECRET # Stripe webhook validation ADMIN_USER_ID # User ID allowed to write prompt contexts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 02207217f..9294667d8 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -76,6 +76,7 @@ values. echo -n "postgres://..." | gcloud secrets create a0p-database-url --data-file=- echo -n "your-session-secret" | gcloud secrets create a0p-session-secret --data-file=- echo -n "xai-key" | gcloud secrets create a0p-xai-api-key --data-file=- +echo -n "deepseek-key" | gcloud secrets create a0p-deepseek-api-key --data-file=- echo -n "sk_live_..." | gcloud secrets create a0p-stripe-secret-key --data-file=- echo -n "whsec_..." | gcloud secrets create a0p-stripe-webhook-secret --data-file=- ``` @@ -83,7 +84,7 @@ echo -n "whsec_..." | gcloud secrets create a0p-stripe-webhook-secret --data-fil Grant the service account access to each secret: ```bash -for SECRET in a0p-database-url a0p-session-secret a0p-xai-api-key a0p-stripe-secret-key a0p-stripe-webhook-secret; do +for SECRET in a0p-database-url a0p-session-secret a0p-xai-api-key a0p-deepseek-api-key a0p-stripe-secret-key a0p-stripe-webhook-secret; do gcloud secrets add-iam-policy-binding $SECRET \ --member="serviceAccount:$SA" \ --role="roles/secretmanager.secretAccessor" @@ -107,8 +108,9 @@ Replit Auth (OIDC) will not work outside Replit. Before going live on Cloud Run ## Pre-deploy checks -Every push runs the **Console tab regression guard** (`scripts/check-console-tabs.mjs`) -as a separate CI job before the build/deploy job. The guard spins up an ephemeral +Every push runs the provider adapter contracts and the **Console tab regression +guard** (`scripts/check-console-tabs.mjs`) as separate CI jobs before the +build/deploy job. The guard spins up an ephemeral Postgres + Python backend in the runner, fetches `/api/v1/ui/structure`, and fails the build if either: @@ -117,8 +119,7 @@ the build if either: 2. `CUSTOM_TAB_RENDERERS` registers a `tab_id` that the API no longer returns (an orphan / dead entry). -The deploy job has `needs: check-console-tabs`, so a failure here blocks the -deploy entirely. +The deploy job needs both gates, so either failure blocks deployment entirely. To run the same check locally against a running dev server: @@ -153,5 +154,6 @@ docker run -p 5000:5000 \ -e DATABASE_URL="..." \ -e SESSION_SECRET="..." \ -e XAI_API_KEY="..." \ + -e DEEPSEEK_API_KEY="..." \ a0p:local ``` diff --git a/README.md b/README.md index b8c26e2b5..61292acaa 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## The Agent -ZFAE (`a0(zeta fun alpha echo)`) is the single persistent agent running on the instrument. Large language models (GPT-5 mini, Gemini 2.5 Flash, Claude Sonnet 4.5, Grok 4 Fast) are treated as **energy providers** — they supply computational energy for each response but are not the agent itself. +ZFAE (`a0(zeta fun alpha echo)`) is the single persistent agent running on the instrument. Large language models (GPT-5 mini, Gemini 2.5 Flash, Claude Sonnet 4.5, Grok 4 Fast, DeepSeek V4) are treated as **energy providers** — they supply computational energy for each response but are not the agent itself. Sub-agents (`a0(zeta{n})`) can be spawned to fork the PCNA instance, execute in parallel, and merge results back into the primary agent. @@ -86,6 +86,21 @@ bash scripts/start-dev.sh In development `scripts/start-dev.sh` generates a shared `INTERNAL_API_SECRET` automatically. +### Standalone a0 / Termux with DeepSeek + +The standalone `run.sh` runtime reads the same `python/config/providers.json` +registry as the FastAPI service. With a DeepSeek key present it auto-selects +V4 Flash through the generic `openai-compatible` adapter. Pin V4 Pro explicitly: + +```bash +export DEEPSEEK_API_KEY="..." +export A0_PROVIDER=deepseek-pro # omit for deepseek-v4-flash +printf '%s\n' '{"task_id":"ds1","input":{"text":"hello"},"tools_allowed":["none"],"mode":"analyze","hmmm":["direct model turn"]}' | ./run.sh +``` + +Provider configuration contains only the credential environment-variable name; +keys remain in the process environment and are never written to source. + ### Useful commands ```bash @@ -118,6 +133,7 @@ npx playwright test | `ANTHROPIC_API_KEY` | Claude Sonnet 4.5 | | `GEMINI_API_KEY` | Gemini 2.5 Flash | | `OPENAI_API_KEY` | GPT-5 mini | +| `DEEPSEEK_API_KEY` | DeepSeek V4 Flash/Pro through the OpenAI-compatible adapter | | `STRIPE_SECRET_KEY` | Stripe (donations + EDCMbone explainer) | | `STRIPE_PUBLISHABLE_KEY` | Stripe embedded checkout | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook HMAC | diff --git a/a0/adapters/__init__.py b/a0/adapters/__init__.py index 9b5386b07..2c54fc225 100644 --- a/a0/adapters/__init__.py +++ b/a0/adapters/__init__.py @@ -1,6 +1,12 @@ -# 3:0 0:0 0:2 +# 9:0 0:0 0:3 from .claude_agent_adapter import ClaudeAgentAdapter +from .openai_compatible_adapter import OpenAICompatibleAdapter from .subagents import ALL_SUBAGENTS, MODE_SUBAGENTS -__all__ = ["ClaudeAgentAdapter", "ALL_SUBAGENTS", "MODE_SUBAGENTS"] -# 3:0 0:0 0:2 +__all__ = [ + "ClaudeAgentAdapter", + "OpenAICompatibleAdapter", + "ALL_SUBAGENTS", + "MODE_SUBAGENTS", +] +# 9:0 0:0 0:3 diff --git a/a0/adapters/openai_adapter.py b/a0/adapters/openai_adapter.py deleted file mode 100644 index f959c5ce1..000000000 --- a/a0/adapters/openai_adapter.py +++ /dev/null @@ -1,2 +0,0 @@ -# 0:0 0:0 0:0 -# 0:0 0:0 0:0 diff --git a/a0/adapters/openai_compatible_adapter.py b/a0/adapters/openai_compatible_adapter.py new file mode 100644 index 000000000..2d2cced91 --- /dev/null +++ b/a0/adapters/openai_compatible_adapter.py @@ -0,0 +1,140 @@ +# 93:29 0:0 2:0 +"""Synchronous standalone adapter for registry-defined OpenAI-compatible APIs.""" +from __future__ import annotations + +# === MODULE_BUILD === +# id: a0_adapter_openai_compatible +# module_name: openai_compatible_adapter +# module_kind: adapter +# summary: Executes standalone a0 requests against any registry-defined OpenAI-compatible Responses or Chat Completions endpoint. +# owner: Erin Spencer +# public_surface: OpenAICompatibleAdapter +# internal_surface: _normalize_effort, _response_text +# auth_boundary: none +# storage_boundary: none +# network_boundary: external +# user_data_boundary: write +# admin_only: false +# tests: tests/test_a0_openai_compatible_adapter.py +# rollout: default_enabled +# rollback: Remove this module and restore router selection to Claude/local only. +# requires: a0_provider_registry +# since: 2026-09-07 +# unresolved: none +# === END MODULE_BUILD === + +# === CONTRACTS === +# id: a0_openai_compatible_completion +# given: a compatible provider id/spec, process credential, and ModelAdapter messages +# then: the registry-derived client executes the configured API family and returns text/usage without credentials; missing keys and unsupported families fail closed +# class: correctness +# since: 2026-09-07 +# === END CONTRACTS === + +import os +from typing import Any, Dict, List + +from openai import OpenAI + +Message = Dict[str, str] + + +def _normalize_effort(spec: dict[str, Any], requested: str | None) -> str | None: + if not spec.get("supports_reasoning_effort"): + return None + value = (requested or spec.get("default_reasoning_effort") or "medium").lower().strip() + mapped = (spec.get("reasoning_effort_map") or {}).get(value, value) + allowed = spec.get("reasoning_efforts") or [] + if allowed and mapped not in allowed: + return spec.get("default_reasoning_effort") or allowed[0] + return mapped + + +def _response_text(response: Any) -> str: + output_text = getattr(response, "output_text", None) + if output_text: + return str(output_text) + data = response.model_dump() + for item in data.get("output") or []: + if item.get("type") != "message": + continue + for part in item.get("content") or []: + if part.get("type") == "output_text" and part.get("text"): + return str(part["text"]) + return "" + + +class OpenAICompatibleAdapter: + """ModelAdapter implementation parameterized entirely by provider data.""" + + def __init__(self, provider_id: str, spec: dict[str, Any]) -> None: + self.provider_id = provider_id + self.spec = dict(spec) + self.model = str(self.spec.get("model") or "").strip() + self.api_key_env = str(self.spec.get("api_key_env") or "").strip() + self.api_family = str(self.spec.get("api_family") or "responses") + base_url = str(self.spec.get("base_url") or "").strip() or None + + if not self.model: + raise ValueError(f"Provider {provider_id!r} has no model") + if not self.api_key_env: + raise ValueError(f"Provider {provider_id!r} has no api_key_env") + api_key = os.environ.get(self.api_key_env, "").strip() + if not api_key: + raise ValueError(f"{self.api_key_env} not configured") + + client_kwargs: dict[str, str] = {"api_key": api_key} + if base_url: + client_kwargs["base_url"] = base_url.rstrip("/") + self._client = OpenAI(**client_kwargs) + self.name = f"{provider_id}:{self.model}" + + def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: + effort = _normalize_effort(self.spec, kwargs.get("reasoning_effort")) + max_tokens = int(kwargs.get("max_tokens") or 4096) + try: + if self.api_family == "responses": + request: dict[str, Any] = { + "model": self.model, + "input": messages, + "max_output_tokens": max_tokens, + } + if effort and effort != "none": + request["reasoning"] = {"effort": effort} + response = self._client.responses.create(**request) + text = _response_text(response) + data = response.model_dump() + elif self.api_family == "chat_completions": + request = { + "model": self.model, + "messages": messages, + "max_tokens": max_tokens, + } + if effort and effort != "none": + request["reasoning_effort"] = effort + response = self._client.chat.completions.create(**request) + data = response.model_dump() + choices = data.get("choices") or [] + message = (choices[0].get("message") if choices else None) or {} + text = str(message.get("content") or "") + else: + raise ValueError( + f"Provider {self.provider_id!r} has unsupported api_family={self.api_family!r}" + ) + except ValueError: + raise + except Exception as exc: + raise RuntimeError( + f"{self.provider_id} request failed: {type(exc).__name__}" + ) from exc + + return { + "text": text or f"[{self.provider_id}: empty response]", + "raw": { + "provider": self.provider_id, + "model": self.model, + "usage": data.get("usage") or {}, + }, + "subagents_used": [], + } +# 93:29 0:0 2:0 diff --git a/a0/provider_registry.py b/a0/provider_registry.py new file mode 100644 index 000000000..968b52c76 --- /dev/null +++ b/a0/provider_registry.py @@ -0,0 +1,78 @@ +# 36:29 0:0 1:0 +"""Read standalone a0 model adapters from the canonical provider registry.""" +from __future__ import annotations + +# === MODULE_BUILD === +# id: a0_provider_registry +# module_name: provider_registry +# module_kind: service +# summary: Resolves explicit or auto-selected OpenAI-compatible standalone a0 providers from python/config/providers.json. +# owner: Erin Spencer +# public_surface: load_provider_registry, resolve_openai_compatible_provider +# internal_surface: _is_openai_compatible +# auth_boundary: none +# storage_boundary: read +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: tests/test_a0_openai_compatible_adapter.py +# rollout: default_enabled +# rollback: Remove this module and restore router selection to Claude/local only. +# requires: none +# since: 2026-09-07 +# unresolved: none +# === END MODULE_BUILD === + +# === CONTRACTS === +# id: a0_provider_selection +# given: optional A0_PROVIDER, canonical provider registry data, and process credentials +# then: an explicit compatible provider resolves or fails closed while an unset choice may auto-select only a configured a0_default provider +# class: correctness +# since: 2026-09-07 +# === END CONTRACTS === + +import json +import os +from pathlib import Path +from typing import Any + +_PROVIDERS_PATH = Path(__file__).resolve().parents[1] / "python" / "config" / "providers.json" + + +def load_provider_registry() -> dict[str, dict[str, Any]]: + with _PROVIDERS_PATH.open("r", encoding="utf-8") as handle: + document = json.load(handle) + return document["providers"] + + +def _is_openai_compatible(spec: dict[str, Any]) -> bool: + return spec.get("adapter") == "openai-compatible" or spec.get("vendor") == "openai" + + +def resolve_openai_compatible_provider( + provider_id: str | None = None, +) -> tuple[str, dict[str, Any]] | None: + """Resolve an explicit provider or the first configured a0_default entry.""" + providers = load_provider_registry() + explicit = (provider_id or os.environ.get("A0_PROVIDER", "")).strip() + if explicit: + spec = providers.get(explicit) + if spec is None: + raise ValueError(f"Unknown A0_PROVIDER: {explicit!r}") + if not _is_openai_compatible(spec): + raise ValueError( + f"A0_PROVIDER {explicit!r} does not use the openai-compatible adapter" + ) + return explicit, spec + + for candidate, spec in providers.items(): + api_key_env = str(spec.get("api_key_env") or "") + if ( + spec.get("a0_default") + and _is_openai_compatible(spec) + and api_key_env + and os.environ.get(api_key_env) + ): + return candidate, spec + return None +# 36:29 0:0 1:0 diff --git a/a0/router.py b/a0/router.py index 2dcef1151..dcef02e35 100644 --- a/a0/router.py +++ b/a0/router.py @@ -1,4 +1,4 @@ -# 59:5 0:0 4:8 +# 65:4 0:0 4:10 from __future__ import annotations import os @@ -7,6 +7,7 @@ from .logging import log_event from .state import load_state, save_state from .model_adapter import LocalEchoAdapter +from .provider_registry import resolve_openai_compatible_provider from .tools.edcm_tool import run_edcm from .tools.pdf_tool import run_pdf_extract @@ -19,10 +20,16 @@ def _select_adapter(req: A0Request): """Select the best available adapter for this request. - Prefers ClaudeAgentAdapter (full PTCA subagent pipeline). - Falls back to LocalEchoAdapter if agent mode is not requested - or if the SDK is unavailable. + An explicit A0_PROVIDER is fail-closed. Otherwise, a configured registry + entry marked a0_default wins, then ClaudeAgentAdapter, then LocalEcho. """ + provider = resolve_openai_compatible_provider() + if provider: + from .adapters.openai_compatible_adapter import OpenAICompatibleAdapter + + provider_id, spec = provider + return OpenAICompatibleAdapter(provider_id, spec) + try: from .adapters.claude_agent_adapter import ClaudeAgentAdapter, _SDK_AVAILABLE except (ImportError, ModuleNotFoundError): @@ -78,4 +85,4 @@ def handle(req: A0Request) -> A0Response: "hmmm": hmmm, }) return A0Response(task_id=req.task_id, result={"text": resp.get("text", ""), "artifacts": []}, hmmm=hmmm) -# 59:5 0:0 4:8 +# 65:4 0:0 4:10 diff --git a/a0_msdmd.ts b/a0_msdmd.ts index 97630ee66..679d97d42 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,8 +1,78 @@ -// 7594:0 0:1 0:1 +// 7796:0 0:1 0:1 import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; export default defineMsdmdCollection({ "declarations": [ + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "a compatible provider id/spec, process credential, and ModelAdapter messages", + "since": "2026-09-07", + "then": "the registry-derived client executes the configured API family and returns text/usage without credentials; missing keys and unsupported families fail closed" + }, + "file": "a0/adapters/openai_compatible_adapter.py", + "id": "a0_openai_compatible_completion" + }, + { + "block": "MODULE_BUILD", + "fields": { + "admin_only": "false", + "auth_boundary": "none", + "internal_surface": "_normalize_effort, _response_text", + "module_kind": "adapter", + "module_name": "openai_compatible_adapter", + "network_boundary": "external", + "owner": "Erin Spencer", + "public_surface": "OpenAICompatibleAdapter", + "requires": "a0_provider_registry", + "rollback": "Remove this module and restore router selection to Claude/local only.", + "rollout": "default_enabled", + "since": "2026-09-07", + "storage_boundary": "none", + "summary": "Executes standalone a0 requests against any registry-defined OpenAI-compatible Responses or Chat Completions endpoint.", + "tests": "tests/test_a0_openai_compatible_adapter.py", + "unresolved": "none", + "user_data_boundary": "write" + }, + "file": "a0/adapters/openai_compatible_adapter.py", + "id": "a0_adapter_openai_compatible" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "optional A0_PROVIDER, canonical provider registry data, and process credentials", + "since": "2026-09-07", + "then": "an explicit compatible provider resolves or fails closed while an unset choice may auto-select only a configured a0_default provider" + }, + "file": "a0/provider_registry.py", + "id": "a0_provider_selection" + }, + { + "block": "MODULE_BUILD", + "fields": { + "admin_only": "false", + "auth_boundary": "none", + "internal_surface": "_is_openai_compatible", + "module_kind": "service", + "module_name": "provider_registry", + "network_boundary": "none", + "owner": "Erin Spencer", + "public_surface": "load_provider_registry, resolve_openai_compatible_provider", + "requires": "none", + "rollback": "Remove this module and restore router selection to Claude/local only.", + "rollout": "default_enabled", + "since": "2026-09-07", + "storage_boundary": "read", + "summary": "Resolves explicit or auto-selected OpenAI-compatible standalone a0 providers from python/config/providers.json.", + "tests": "tests/test_a0_openai_compatible_adapter.py", + "unresolved": "none", + "user_data_boundary": "none" + }, + "file": "a0/provider_registry.py", + "id": "a0_provider_registry" + }, { "block": "BOUNDARIES", "fields": { @@ -1323,7 +1393,7 @@ export default defineMsdmdCollection({ "rollout": "default_enabled", "since": "2026-06-02", "storage_boundary": "read", - "summary": "Orchestrates LLM calls across registered energy providers (Grok/Gemini/Claude/OpenAI-style) \u2014 resolves role, normalizes reasoning effort, runs the tool loop, and injects tier-specific prompt_context.", + "summary": "Orchestrates LLM calls across registered energy providers (Grok/Gemini/Claude/OpenAI-compatible) \u2014 resolves role, normalizes reasoning effort, runs the tool loop, and injects tier-specific prompt_context.", "tests": "hmmm", "unresolved": "none", "user_data_boundary": "write" @@ -1547,6 +1617,41 @@ export default defineMsdmdCollection({ "file": "python/services/providers/gemini_provider.py", "id": "a0_service_providers_gemini" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "a registered OpenAI-compatible provider id, messages, and optional model/key/effort overrides", + "since": "2026-09-07", + "then": "endpoint, model, credential name, API family, effort scale, and tool profile come from providers.json; missing explicit configuration fails closed and credentials do not enter error text" + }, + "file": "python/services/providers/openai_compatible_provider.py", + "id": "openai_compatible_registry_driven" + }, + { + "block": "MODULE_BUILD", + "fields": { + "admin_only": "false", + "auth_boundary": "none", + "internal_surface": "_call_responses, _call_chat_completions, _normalize_reasoning_effort, _response_tools", + "module_kind": "adapter", + "module_name": "openai_compatible_provider", + "network_boundary": "external", + "owner": "Erin Spencer", + "public_surface": "call", + "requires": "a0_service_providers_resolver, a0_service_tool_executor, a0_service_tool_distill, a0_service_inference, a0_service_energy_registry", + "rollback": "Revert this module and remove registry entries whose adapter is openai-compatible.", + "rollout": "default_enabled", + "since": "2026-09-07", + "storage_boundary": "none", + "summary": "Registry-driven OpenAI-compatible transport supporting Responses and Chat Completions with the shared repeat-safe tool loop.", + "tests": "tests/test_openai_compatible_provider.py", + "unresolved": "none", + "user_data_boundary": "write" + }, + "file": "python/services/providers/openai_compatible_provider.py", + "id": "a0_service_providers_openai_compatible" + }, { "block": "MODULE_BUILD", "fields": { @@ -1558,13 +1663,13 @@ export default defineMsdmdCollection({ "network_boundary": "external", "owner": "Erin Spencer", "public_surface": "call", - "requires": "a0_service_providers_resolver, a0_service_tool_executor, a0_service_tool_distill, a0_service_inference", - "rollback": "Revert this file; OpenAI calls revert to the prior httpx-based implementation.", + "requires": "a0_service_providers_openai_compatible", + "rollback": "Restore the former OpenAI-only Responses implementation.", "rollout": "default_enabled", "since": "2026-06-02", "storage_boundary": "none", - "summary": "OpenAI GPT-5-family provider adapter using the Responses API via the openai SDK \u2014 exposes the standard async call(...) -> (content, usage) with the shared tool-loop contract.", - "tests": "hmmm", + "summary": "Stable OpenAI-specific call surface delegating transport behavior to the generic OpenAI-compatible adapter.", + "tests": "tests/test_openai_compatible_provider.py", "unresolved": "none", "user_data_boundary": "write" }, @@ -3095,6 +3200,19 @@ export default defineMsdmdCollection({ "file": "python/tests/test_contract_runner.py", "id": "check_contract_graph_rejects_incomplete_linkage" }, + { + "block": "CHECKS", + "fields": { + "call": "self::check_openai_compatible_registry_wiring", + "cleanup": "none", + "mutates": "none", + "proves": "openai_compatible_registry_driven, a0_provider_selection, a0_openai_compatible_completion", + "requires": "python3", + "timeout": "20" + }, + "file": "python/tests/test_openai_compatible_contracts.py", + "id": "check_openai_compatible_registry_wiring" + }, { "block": "CHECKS", "fields": { @@ -4951,6 +5069,41 @@ export default defineMsdmdCollection({ "source_id": "check_live_schema_capture_read_only", "to": "python3" }, + { + "from": "check_openai_compatible_registry_wiring", + "kind": "calls", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_registry_wiring", + "to": "self::check_openai_compatible_registry_wiring" + }, + { + "from": "check_openai_compatible_registry_wiring", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_registry_wiring", + "to": "a0_openai_compatible_completion" + }, + { + "from": "check_openai_compatible_registry_wiring", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_registry_wiring", + "to": "a0_provider_selection" + }, + { + "from": "check_openai_compatible_registry_wiring", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_registry_wiring", + "to": "openai_compatible_registry_driven" + }, + { + "from": "check_openai_compatible_registry_wiring", + "kind": "requires", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_registry_wiring", + "to": "python3" + }, { "from": "check_platonic_agent_existing_separations_preserved", "kind": "calls", @@ -6113,6 +6266,20 @@ export default defineMsdmdCollection({ "source_id": "check_transcript_explainer_refund", "to": "python3" }, + { + "from": "a0_adapter_openai_compatible", + "kind": "owns", + "source_block": "MODULE_BUILD", + "source_id": "a0_adapter_openai_compatible", + "to": "Erin Spencer" + }, + { + "from": "a0_adapter_openai_compatible", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_adapter_openai_compatible", + "to": "a0_provider_registry" + }, { "from": "a0_alembic_environment", "kind": "owns", @@ -6435,6 +6602,20 @@ export default defineMsdmdCollection({ "source_id": "a0_platonic_ptcna_state", "to": "ptcna_runtime_boundary" }, + { + "from": "a0_provider_registry", + "kind": "owns", + "source_block": "MODULE_BUILD", + "source_id": "a0_provider_registry", + "to": "Erin Spencer" + }, + { + "from": "a0_provider_registry", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_provider_registry", + "to": "none" + }, { "from": "a0_runtime_readiness", "kind": "owns", @@ -6965,27 +7146,48 @@ export default defineMsdmdCollection({ "kind": "requires", "source_block": "MODULE_BUILD", "source_id": "a0_service_providers_openai", + "to": "a0_service_providers_openai_compatible" + }, + { + "from": "a0_service_providers_openai_compatible", + "kind": "owns", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_providers_openai_compatible", + "to": "Erin Spencer" + }, + { + "from": "a0_service_providers_openai_compatible", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_providers_openai_compatible", + "to": "a0_service_energy_registry" + }, + { + "from": "a0_service_providers_openai_compatible", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_providers_openai_compatible", "to": "a0_service_inference" }, { - "from": "a0_service_providers_openai", + "from": "a0_service_providers_openai_compatible", "kind": "requires", "source_block": "MODULE_BUILD", - "source_id": "a0_service_providers_openai", + "source_id": "a0_service_providers_openai_compatible", "to": "a0_service_providers_resolver" }, { - "from": "a0_service_providers_openai", + "from": "a0_service_providers_openai_compatible", "kind": "requires", "source_block": "MODULE_BUILD", - "source_id": "a0_service_providers_openai", + "source_id": "a0_service_providers_openai_compatible", "to": "a0_service_tool_distill" }, { - "from": "a0_service_providers_openai", + "from": "a0_service_providers_openai_compatible", "kind": "requires", "source_block": "MODULE_BUILD", - "source_id": "a0_service_providers_openai", + "source_id": "a0_service_providers_openai_compatible", "to": "a0_service_tool_executor" }, { @@ -7594,4 +7796,4 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 7594:0 0:1 0:1 +// 7796:0 0:1 0:1 diff --git a/capacitor.config.ts b/capacitor.config.ts index 725fbff04..7e78a5b30 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -1,3 +1,4 @@ +// 13:0 0:1 0:1 import type { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { @@ -13,3 +14,4 @@ const config: CapacitorConfig = { }; export default config; +// 13:0 0:1 0:1 diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 346826d2f..4be9e4963 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -15,6 +15,27 @@ env: REGISTRY: us-central1-docker.pkg.dev jobs: + provider-contracts: + name: Provider adapter contracts + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install Python dependencies + run: pip install -e . pytest pytest-asyncio + + - name: Run provider contracts + run: pytest -q tests/test_openai_compatible_provider.py tests/test_a0_openai_compatible_adapter.py tests/test_a0_package.py python/tests/test_openai_compatible_contracts.py + check-console-tabs: name: Console tab regression guard runs-on: ubuntu-latest @@ -93,7 +114,7 @@ jobs: deploy: name: Build, push, deploy - needs: check-console-tabs + needs: [provider-contracts, check-console-tabs] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: @@ -152,4 +173,4 @@ jobs: --max-instances=10 \ --memory=512Mi \ --cpu=1 \ - --set-secrets="DATABASE_URL=a0p-database-url:latest,SESSION_SECRET=a0p-session-secret:latest,XAI_API_KEY=a0p-xai-api-key:latest,STRIPE_SECRET_KEY=a0p-stripe-secret-key:latest,STRIPE_WEBHOOK_SECRET=a0p-stripe-webhook-secret:latest" + --set-secrets="DATABASE_URL=a0p-database-url:latest,SESSION_SECRET=a0p-session-secret:latest,XAI_API_KEY=a0p-xai-api-key:latest,DEEPSEEK_API_KEY=a0p-deepseek-api-key:latest,STRIPE_SECRET_KEY=a0p-stripe-secret-key:latest,STRIPE_WEBHOOK_SECRET=a0p-stripe-webhook-secret:latest" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6e2a1881..6b7762d21 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,13 +14,16 @@ - `python/agents/zfae.py` — ZFAE agent definition, compose_name(), sub_agent_name() - `python/services/energy_registry.py` — LLM provider registry (loads `python/config/providers.json`) - `python/services/inference.py` — Dispatcher + orchestration; delegates outbound API calls to `providers/.py` -- `python/services/providers/` — One file per provider: - - `_resolver.py` — env > seed route_config > spec model lookup; raises on unresolvable - - `openai_provider.py` — OpenAI Responses API + tool loop +- `python/services/providers/` — Native adapters plus generic transports: + - `_resolver.py` — registry-defined env override > spec model lookup; raises on unresolvable + - `openai_compatible_provider.py` — provider-neutral Responses/Chat Completions transport + tool loop + - `openai_provider.py` — stable OpenAI wrapper over the generic transport - `xai_provider.py` — xAI Grok via native xai-sdk (search + function-tool loop + streaming) - `gemini_provider.py` — google-genai SDK (thin wrapper over `gemini_native.py`) - `claude_provider.py` — Anthropic SDK + prompt caching - `python/services/provider_seeds_bootstrap.py` — Lifespan-time idempotent seeding of provider WS modules +- `a0/provider_registry.py` — standalone/Termux selection from the canonical provider JSON; explicit `A0_PROVIDER` fails closed +- `a0/adapters/openai_compatible_adapter.py` — synchronous standalone transport for registry-defined compatible providers - `python/services/heartbeat.py` — Background heartbeat service (30s tick) - `python/services/bandit.py` — Multi-Armed Bandit (UCB1) service - `python/services/edcm.py` — EDCM behavioral directives scoring @@ -120,6 +123,7 @@ Anthropic gets two cache breakpoints (before/after `## Memory`). OpenAI/Grok aut | Provider | Cache read | Cache write | |----------|-----------|-------------| | openai (gpt-5-mini) | 10% input | n/a (auto) | +| deepseek V4 | automatic discounted input | n/a (auto) | | claude sonnet 4.5 | 10% input | 125% input | | grok 4 fast | 25% input | n/a (auto) | | gemini 2.5 flash | not wired | requires cachedContents API | diff --git a/python/config/pricing.json b/python/config/pricing.json index ae255d3b5..6a29aff02 100644 --- a/python/config/pricing.json +++ b/python/config/pricing.json @@ -1,6 +1,6 @@ { - "version": "2026-04-27.a0.pricing.v1", - "prices_as_of": "2026-04-27", + "version": "2026-09-07.a0.pricing.v2", + "prices_as_of": "2026-09-07", "_doc": { "schema": "providers..models is a list of {id, context_window, input_per_1m, output_per_1m, cached_input_per_1m?, cache_write_per_1m?, supports_vision?, supports_thinking?, note?} entries. All prices are USD per 1,000,000 tokens.", "source-of-truth": "Boot source-of-truth for per-model pricing. estimate_cost(provider_id, ..., model=) consults this file first and uses per-model rates; without a model arg, falls back to the provider flagship rate from providers.json. Hydrates each ws_modules.route_config.available_models list on first boot and on POST /api/energy/refresh-pricing/{provider_id}.", @@ -182,6 +182,34 @@ "supports_vision": true } ] + }, + "deepseek": { + "pricing_url": "https://api-docs.deepseek.com/quick_start/pricing/", + "models": [ + { + "id": "deepseek-v4-flash", + "context_window": 1000000, + "input_per_1m": 0.44, + "output_per_1m": 1.32, + "cached_input_per_1m": 0.014, + "supports_thinking": true, + "note": "Official peak rates; off-peak rates are lower." + } + ] + }, + "deepseek-pro": { + "pricing_url": "https://api-docs.deepseek.com/quick_start/pricing/", + "models": [ + { + "id": "deepseek-v4-pro", + "context_window": 1000000, + "input_per_1m": 1.32, + "output_per_1m": 3.96, + "cached_input_per_1m": 0.044, + "supports_thinking": true, + "note": "Official peak rates; off-peak rates are lower." + } + ] } } } diff --git a/python/config/providers.json b/python/config/providers.json index 0a832e7bd..9d7ff3dce 100644 --- a/python/config/providers.json +++ b/python/config/providers.json @@ -1,8 +1,8 @@ { - "version": "2026-04-27.a0.providers.v1", + "version": "2026-09-07.a0.providers.v2", "attribution": "Externalized from energy_registry.py BUILTIN_PROVIDERS + _PROVIDER_PRESETS + _PROVIDER_PRICING_URLS so model slugs / provider IDs / capability flags live as data, not code literals (doctrine: no executable-data string literals).", "_doc": { - "providers[].* fields": "id label model vendor env_key cost_per_1k_input cost_per_1k_output cache_read_per_1k_input cache_write_per_1k_input max_tokens supports_streaming supports_prompt_caching supports_thinking supports_reasoning_effort api_family min_tier note pricing_url", + "providers[].* fields": "id label model adapter vendor base_url api_key_env model_env_prefix api_family tool_profile reasoning_efforts reasoning_effort_map cost_per_1k_input cost_per_1k_output cache_read_per_1k_input cache_write_per_1k_input max_tokens supports_streaming supports_prompt_caching supports_thinking supports_reasoning_effort min_tier note pricing_url", "presets": "Per-provider role-map keyed by preset name. Roles: record practice conduct perform derive (the renamed pipeline slots). Single-model providers pin every preset to their one model so the active_preset switch still has effect." }, "providers": { @@ -10,14 +10,17 @@ "id": "openai", "label": "GPT-5 mini (Responses API)", "model": "gpt-5-mini", + "adapter": "openai-compatible", "vendor": "openai", - "env_key": "OPENAI_API_KEY", + "api_key_env": "OPENAI_API_KEY", "cost_per_1k_input": 0.00025, "cost_per_1k_output": 0.002, "cache_read_per_1k_input": 2.5e-05, "max_tokens": 128000, "supports_streaming": false, "supports_prompt_caching": true, + "supports_reasoning_effort": true, + "supports_store": true, "api_family": "responses", "pricing_url": "https://openai.com/api/pricing/", "note": "gpt-5-mini default; cached input 90% off (automatic on >=1024 token prefixes)", @@ -28,7 +31,7 @@ "label": "Gemini 2.5 Flash", "model": "gemini-2.5-flash", "vendor": "google", - "env_key": "GEMINI_API_KEY", + "api_key_env": "GEMINI_API_KEY", "cost_per_1k_input": 0.0003, "cost_per_1k_output": 0.0025, "cache_read_per_1k_input": 7.5e-05, @@ -43,7 +46,7 @@ "label": "Gemini 3 Pro", "model": "gemini-3-pro-preview", "vendor": "google", - "env_key": "GEMINI_API_KEY", + "api_key_env": "GEMINI_API_KEY", "cost_per_1k_input": 0.00125, "cost_per_1k_output": 0.01, "cache_read_per_1k_input": 0.00031, @@ -60,7 +63,7 @@ "label": "Claude Sonnet 4.5", "model": "claude-sonnet-4-5", "vendor": "anthropic", - "env_key": "ANTHROPIC_API_KEY", + "api_key_env": "ANTHROPIC_API_KEY", "cost_per_1k_input": 0.003, "cost_per_1k_output": 0.015, "cache_read_per_1k_input": 0.0003, @@ -76,7 +79,7 @@ "label": "Grok 4 Fast (reasoning)", "model": "grok-4-fast-reasoning", "vendor": "xai", - "env_key": "XAI_API_KEY", + "api_key_env": "XAI_API_KEY", "cost_per_1k_input": 0.0002, "cost_per_1k_output": 0.0005, "cache_read_per_1k_input": 5e-05, @@ -95,8 +98,9 @@ "id": "openai-5.5", "label": "GPT-5.5 (Responses API)", "model": "gpt-5.5", + "adapter": "openai-compatible", "vendor": "openai", - "env_key": "OPENAI_API_KEY", + "api_key_env": "OPENAI_API_KEY", "cost_per_1k_input": 0.005, "cost_per_1k_output": 0.03, "cache_read_per_1k_input": 0.0005, @@ -104,6 +108,7 @@ "supports_streaming": false, "supports_prompt_caching": true, "supports_reasoning_effort": true, + "supports_store": true, "supports_vision": true, "api_family": "responses", "pricing_url": "https://openai.com/api/pricing/", @@ -114,8 +119,9 @@ "id": "openai-5.5-pro", "label": "GPT-5.5 Pro (Responses API)", "model": "gpt-5.5-pro", + "adapter": "openai-compatible", "vendor": "openai", - "env_key": "OPENAI_API_KEY", + "api_key_env": "OPENAI_API_KEY", "cost_per_1k_input": 0.03, "cost_per_1k_output": 0.18, "cache_read_per_1k_input": 0.003, @@ -123,6 +129,7 @@ "supports_streaming": false, "supports_prompt_caching": true, "supports_reasoning_effort": true, + "supports_store": true, "supports_vision": true, "api_family": "responses", "pricing_url": "https://openai.com/api/pricing/", @@ -134,14 +141,17 @@ "id": "openai-nano", "label": "GPT-5 nano", "model": "gpt-5-nano", + "adapter": "openai-compatible", "vendor": "openai", - "env_key": "OPENAI_API_KEY", + "api_key_env": "OPENAI_API_KEY", "cost_per_1k_input": 0.00008, "cost_per_1k_output": 0.00032, "cache_read_per_1k_input": 8e-06, "max_tokens": 128000, "supports_streaming": false, "supports_prompt_caching": true, + "supports_reasoning_effort": true, + "supports_store": true, "api_family": "responses", "pricing_url": "https://openai.com/api/pricing/", "note": "Fastest/cheapest OpenAI tier — high-volume record/derive slots.", @@ -151,8 +161,9 @@ "id": "openai-flagship", "label": "GPT-5", "model": "gpt-5", + "adapter": "openai-compatible", "vendor": "openai", - "env_key": "OPENAI_API_KEY", + "api_key_env": "OPENAI_API_KEY", "cost_per_1k_input": 0.015, "cost_per_1k_output": 0.06, "cache_read_per_1k_input": 0.0015, @@ -160,6 +171,7 @@ "supports_streaming": false, "supports_prompt_caching": true, "supports_reasoning_effort": true, + "supports_store": true, "supports_vision": true, "api_family": "responses", "pricing_url": "https://openai.com/api/pricing/", @@ -171,7 +183,7 @@ "label": "Gemini 2.5 Flash Lite", "model": "gemini-2.5-flash-lite", "vendor": "google", - "env_key": "GEMINI_API_KEY", + "api_key_env": "GEMINI_API_KEY", "cost_per_1k_input": 0.00004, "cost_per_1k_output": 0.0004, "cache_read_per_1k_input": 0.00001, @@ -186,7 +198,7 @@ "label": "Gemini 2.5 Pro", "model": "gemini-2.5-pro", "vendor": "google", - "env_key": "GEMINI_API_KEY", + "api_key_env": "GEMINI_API_KEY", "cost_per_1k_input": 0.00125, "cost_per_1k_output": 0.01, "cache_read_per_1k_input": 0.0003125, @@ -203,7 +215,7 @@ "label": "Claude Haiku 4.5", "model": "claude-haiku-4-5", "vendor": "anthropic", - "env_key": "ANTHROPIC_API_KEY", + "api_key_env": "ANTHROPIC_API_KEY", "cost_per_1k_input": 0.0008, "cost_per_1k_output": 0.004, "cache_read_per_1k_input": 0.00008, @@ -220,7 +232,7 @@ "label": "Claude Opus 4.1", "model": "claude-opus-4-1", "vendor": "anthropic", - "env_key": "ANTHROPIC_API_KEY", + "api_key_env": "ANTHROPIC_API_KEY", "cost_per_1k_input": 0.015, "cost_per_1k_output": 0.075, "cache_read_per_1k_input": 0.0015, @@ -238,7 +250,7 @@ "label": "Grok 4", "model": "grok-4", "vendor": "xai", - "env_key": "XAI_API_KEY", + "api_key_env": "XAI_API_KEY", "cost_per_1k_input": 0.003, "cost_per_1k_output": 0.015, "cache_read_per_1k_input": 0.0003, @@ -257,7 +269,7 @@ "label": "Grok 4 Fast (no reasoning)", "model": "grok-4-fast-non-reasoning", "vendor": "xai", - "env_key": "XAI_API_KEY", + "api_key_env": "XAI_API_KEY", "cost_per_1k_input": 0.0002, "cost_per_1k_output": 0.0005, "cache_read_per_1k_input": 5e-05, @@ -274,7 +286,7 @@ "label": "Grok Code Fast 1", "model": "grok-code-fast-1", "vendor": "xai", - "env_key": "XAI_API_KEY", + "api_key_env": "XAI_API_KEY", "cost_per_1k_input": 0.0002, "cost_per_1k_output": 0.0005, "cache_read_per_1k_input": 5e-05, @@ -285,6 +297,78 @@ "note": "Code-specialized Grok — coding presets.", "url": "https://api.x.ai/v1/chat/completions", "supports_vision": false + }, + "deepseek": { + "id": "deepseek", + "label": "DeepSeek V4 Flash", + "model": "deepseek-v4-flash", + "adapter": "openai-compatible", + "vendor": "openai-compatible", + "base_url": "https://api.deepseek.com", + "api_key_env": "DEEPSEEK_API_KEY", + "model_env_prefix": "DEEPSEEK_MODEL_", + "api_family": "responses", + "tool_profile": "functions-only", + "reasoning_efforts": ["low", "high", "max"], + "reasoning_effort_map": { + "none": "low", + "minimal": "low", + "low": "low", + "medium": "high", + "high": "high", + "xhigh": "max", + "max": "max" + }, + "default_reasoning_effort": "high", + "a0_default": true, + "cost_per_1k_input": 0.00044, + "cost_per_1k_output": 0.00132, + "cache_read_per_1k_input": 0.000014, + "max_tokens": 1000000, + "supports_streaming": false, + "supports_prompt_caching": true, + "supports_thinking": true, + "supports_reasoning_effort": true, + "supports_store": false, + "supports_vision": false, + "pricing_url": "https://api-docs.deepseek.com/quick_start/pricing/", + "note": "Default standalone a0 energy; official V4 alias and peak token rates as of 2026-09-07." + }, + "deepseek-pro": { + "id": "deepseek-pro", + "label": "DeepSeek V4 Pro", + "model": "deepseek-v4-pro", + "adapter": "openai-compatible", + "vendor": "openai-compatible", + "base_url": "https://api.deepseek.com", + "api_key_env": "DEEPSEEK_API_KEY", + "model_env_prefix": "DEEPSEEK_PRO_MODEL_", + "api_family": "responses", + "tool_profile": "functions-only", + "reasoning_efforts": ["low", "high", "max"], + "reasoning_effort_map": { + "none": "low", + "minimal": "low", + "low": "low", + "medium": "high", + "high": "high", + "xhigh": "max", + "max": "max" + }, + "default_reasoning_effort": "high", + "cost_per_1k_input": 0.00132, + "cost_per_1k_output": 0.00396, + "cache_read_per_1k_input": 0.000044, + "max_tokens": 1000000, + "supports_streaming": false, + "supports_prompt_caching": true, + "supports_thinking": true, + "supports_reasoning_effort": true, + "supports_store": false, + "supports_vision": false, + "min_tier": "ws", + "pricing_url": "https://api-docs.deepseek.com/quick_start/pricing/", + "note": "Higher-capability standalone a0 option; official V4 alias and peak token rates as of 2026-09-07." } }, "presets": { @@ -595,6 +679,94 @@ "perform": "gpt-5.5-pro", "derive": "gpt-5.5-pro" } + }, + "deepseek": { + "speed": { + "record": "deepseek-v4-flash", + "practice": "deepseek-v4-flash", + "conduct": "deepseek-v4-flash", + "perform": "deepseek-v4-flash", + "derive": "deepseek-v4-flash" + }, + "price": { + "record": "deepseek-v4-flash", + "practice": "deepseek-v4-flash", + "conduct": "deepseek-v4-flash", + "perform": "deepseek-v4-flash", + "derive": "deepseek-v4-flash" + }, + "balance": { + "record": "deepseek-v4-flash", + "practice": "deepseek-v4-flash", + "conduct": "deepseek-v4-flash", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-flash" + }, + "depth": { + "record": "deepseek-v4-flash", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-pro" + }, + "coding": { + "record": "deepseek-v4-flash", + "practice": "deepseek-v4-flash", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-flash" + }, + "creativity": { + "record": "deepseek-v4-flash", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-flash" + } + }, + "deepseek-pro": { + "speed": { + "record": "deepseek-v4-pro", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-pro" + }, + "price": { + "record": "deepseek-v4-pro", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-pro" + }, + "balance": { + "record": "deepseek-v4-pro", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-pro" + }, + "depth": { + "record": "deepseek-v4-pro", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-pro" + }, + "coding": { + "record": "deepseek-v4-pro", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-pro" + }, + "creativity": { + "record": "deepseek-v4-pro", + "practice": "deepseek-v4-pro", + "conduct": "deepseek-v4-pro", + "perform": "deepseek-v4-pro", + "derive": "deepseek-v4-pro" + } } } } diff --git a/python/main.py b/python/main.py index e5c4de55f..17f830eb4 100644 --- a/python/main.py +++ b/python/main.py @@ -1,4 +1,4 @@ -# 329:312 0:0 10:20 +# 329:314 0:0 10:20 import os import time from contextlib import asynccontextmanager @@ -675,4 +675,4 @@ async def ui_structure(): @app.get("/{full_path:path}", include_in_schema=False) async def serve_spa(full_path: str): return FileResponse(os.path.join(STATIC_DIR, "index.html")) -# 329:312 0:0 10:20 +# 329:314 0:0 10:20 diff --git a/python/services/energy_registry.py b/python/services/energy_registry.py index 508c3698d..aee5d418a 100644 --- a/python/services/energy_registry.py +++ b/python/services/energy_registry.py @@ -1,4 +1,4 @@ -# 289:88 0:0 19:3 +# 289:88 0:0 20:3 # === MODULE_BUILD === # id: a0_service_energy_registry # module_name: energy_registry @@ -98,8 +98,8 @@ def default_provider() -> str | None: the async active_provider() which reads the conduct slot from the DB. """ for pid, info in BUILTIN_PROVIDERS.items(): - env_key = info.get("env_key", "") - if env_key and os.environ.get(env_key): + api_key_env = info.get("api_key_env", "") + if api_key_env and os.environ.get(api_key_env): return pid return None @@ -157,8 +157,8 @@ def cheap_provider() -> str | None: """ for pid in _CHEAP_PROVIDER_ORDER: info = BUILTIN_PROVIDERS.get(pid, {}) - env_key = info.get("env_key", "") - if env_key and os.environ.get(env_key): + api_key_env = info.get("api_key_env", "") + if api_key_env and os.environ.get(api_key_env): return pid return default_provider() @@ -400,8 +400,8 @@ def build_model_instances() -> dict: from aimmh_lib import ModelInstance out: dict = {} for pid, info in BUILTIN_PROVIDERS.items(): - env_key = info.get("env_key", "") - if env_key and not os.environ.get(env_key): + api_key_env = info.get("api_key_env", "") + if api_key_env and not os.environ.get(api_key_env): continue out[pid] = ModelInstance(_aimmh_call_fn, pid) return out @@ -432,4 +432,4 @@ async def resolve_providers(providers: list[str] | None) -> list[str]: elif p in BUILTIN_PROVIDERS and p not in out: out.append(p) return out -# 289:88 0:0 19:3 +# 289:88 0:0 20:3 diff --git a/python/services/inference.py b/python/services/inference.py index d094dd752..f89d2c1c2 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,9 +1,9 @@ -# 393:107 0:0 16:14 +# 406:107 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference # module_kind: service -# summary: Orchestrates LLM calls across registered energy providers (Grok/Gemini/Claude/OpenAI-style) — resolves role, normalizes reasoning effort, runs the tool loop, and injects tier-specific prompt_context. +# summary: Orchestrates LLM calls across registered energy providers (Grok/Gemini/Claude/OpenAI-compatible) — resolves role, normalizes reasoning effort, runs the tool loop, and injects tier-specific prompt_context. # owner: Erin Spencer # public_surface: call_provider # internal_surface: _instance_memory_block, _slot_instance_block, _slot_routing_info, _sanitize_provider_error, _canonical_tool_calls, _gate_to_effort, _effort_to_thinking_budget, _call_openai_routed, _call_anthropic @@ -55,9 +55,10 @@ async def _instance_memory_block(provider_id: str) -> str: /api/v1/agents/instances/{id}/memory (admin). Deleting all entries and clearing swarm_context on the instance stops injection entirely. """ - from ..database import get_session - from sqlalchemy import text as _sa_text try: + from ..database import get_session + from sqlalchemy import text as _sa_text + spec = BUILTIN_PROVIDERS.get(provider_id, {}) model_id = (spec.get("model") or "").strip() if not model_id: @@ -107,9 +108,10 @@ async def _slot_routing_info(slot: str) -> tuple[str, "str | None"]: Returns ("", None) when no instance is assigned, on any error, or when the model_id does not match any known provider — inference is never blocked. """ - from ..database import get_session - from sqlalchemy import text as _sa_text try: + from ..database import get_session + from sqlalchemy import text as _sa_text + async with get_session() as session: inst = (await session.execute(_sa_text( "SELECT id, model_id, swarm_context FROM model_instances " @@ -332,15 +334,15 @@ async def call_provider( # OpenAI-vendored single-model providers (openai-5.5, openai-5.5-pro and # any future siblings). The legacy "openai" provider above goes through - # role-based router; these go straight to openai_provider.call with the - # spec's pinned model. reasoning_effort is clamped UP to the spec's + # role-based router; these go through the generic compatible transport + # with the spec's pinned model. reasoning_effort is clamped UP to the spec's # min_reasoning_effort (no silent downgrade — gpt-5.5-pro returns HTTP # 400 on 'low', so we honor the floor verbatim, not silently swallow). if spec.get("vendor") == "openai": - api_key = os.environ.get(spec["env_key"], "") + api_key = os.environ.get(spec["api_key_env"], "") if not api_key: raise RuntimeError( - f"{provider_id} unavailable: env var {spec['env_key']} is not set. " + f"{provider_id} unavailable: env var {spec['api_key_env']} is not set. " f"Set the API key or route the request to a configured provider." ) effective_effort = (reasoning_effort or "medium").lower() @@ -353,9 +355,10 @@ async def call_provider( if system_prompt: payload_messages.append({"role": "system", "content": system_prompt}) payload_messages.extend(messages) - from .providers.openai_provider import call as openai_call - return await openai_call( + from .providers.openai_compatible_provider import call as compatible_call + return await compatible_call( payload_messages, + provider_id=provider_id, model_override=spec["model"], api_key=api_key, max_tokens=max_tokens, @@ -363,10 +366,10 @@ async def call_provider( reasoning_effort=effective_effort, ) - api_key = os.environ.get(spec["env_key"], "") + api_key = os.environ.get(spec["api_key_env"], "") if not api_key: raise RuntimeError( - f"{provider_id} unavailable: env var {spec['env_key']} is not set. " + f"{provider_id} unavailable: env var {spec['api_key_env']} is not set. " f"Set the API key or route the request to a configured provider." ) @@ -377,6 +380,19 @@ async def call_provider( vendor = spec.get("vendor", "") + if spec.get("adapter") == "openai-compatible": + from .providers.openai_compatible_provider import call as compatible_call + return await compatible_call( + payload_messages, + provider_id=provider_id, + api_key=api_key, + model_override=spec["model"], + max_tokens=max_tokens, + use_tools=use_tools, + reasoning_effort=reasoning_effort, + progress_callback=progress_callback, + ) + if vendor == "anthropic": return await _call_anthropic( api_key, spec["model"], payload_messages, max_tokens, @@ -561,4 +577,4 @@ async def _call_anthropic( ) -# 393:107 0:0 16:14 +# 406:107 0:0 16:15 diff --git a/python/services/model_catalog.py b/python/services/model_catalog.py index 59eee10d4..6f11379a7 100644 --- a/python/services/model_catalog.py +++ b/python/services/model_catalog.py @@ -1,4 +1,4 @@ -# 108:86 0:0 7:1 +# 109:89 0:0 7:1 """model_catalog — single source of truth for "what models can this user use". Today three surfaces answer this question independently: @@ -70,9 +70,13 @@ def _resolve_static(model_id: str) -> Optional[tuple[str, dict]]: """ if model_id in BUILTIN_PROVIDERS: return model_id, BUILTIN_PROVIDERS[model_id] + # Every provider's primary model is more authoritative than any optimizer + # preset reference. This prevents a shared preset model from being + # attributed to whichever provider happens to appear first in JSON. for pid, spec in BUILTIN_PROVIDERS.items(): if spec.get("model") == model_id: return pid, spec + for pid, spec in BUILTIN_PROVIDERS.items(): presets = _PROVIDER_PRESETS.get(pid, {}) for role_map in presets.values(): if isinstance(role_map, dict) and model_id in role_map.values(): @@ -106,11 +110,11 @@ async def resolve_model_id(model_id: str) -> tuple[str, dict]: async def is_provider_enabled(provider_id: str) -> bool: """Providers are enabled when their API key env var is present.""" spec = BUILTIN_PROVIDERS.get(provider_id, {}) - env_key = spec.get("env_key") - if not env_key: + api_key_env = spec.get("api_key_env") + if not api_key_env: return True import os - return bool(os.environ.get(env_key)) + return bool(os.environ.get(api_key_env)) async def _user_tier(user_id: Optional[str]) -> str: @@ -160,9 +164,9 @@ async def list_models_for_user(user_id: Optional[str]) -> dict[str, Any]: cfgs: dict[str, dict] = {} for pid, spec in BUILTIN_PROVIDERS.items(): - env_key = spec.get("env_key") + api_key_env = spec.get("api_key_env") import os - key_present = bool(env_key and os.environ.get(env_key)) + key_present = bool(api_key_env and os.environ.get(api_key_env)) cfg = cfgs.get(pid, {}) enabled = cfg.get("enabled", True) min_tier = spec.get("min_tier") @@ -225,4 +229,4 @@ def _touch(mid: str) -> dict: }) return {"user_tier": user_tier, "providers": out_providers} -# 108:86 0:0 7:1 +# 109:89 0:0 7:1 diff --git a/python/services/providers/__init__.py b/python/services/providers/__init__.py index 917720bff..bd5eb4988 100644 --- a/python/services/providers/__init__.py +++ b/python/services/providers/__init__.py @@ -14,8 +14,8 @@ async def call( **kwargs, ) -> tuple[str, dict] -`role` selects the model via env > seed `route_config.model_assignments[role]` -> provider spec primary (see _resolver.resolve_model_for_role). The +`role` selects the model via the registry-defined environment override, then +the provider spec primary (see _resolver.resolve_model_for_role). The `model_override` escape hatch is for legacy callers in inference.py that already know the model id and just want SDK delivery; new callers should pass `role` instead and let the resolver pick. diff --git a/python/services/providers/_resolver.py b/python/services/providers/_resolver.py index baf3d4814..1dc2ea277 100644 --- a/python/services/providers/_resolver.py +++ b/python/services/providers/_resolver.py @@ -1,4 +1,4 @@ -# 28:45 0:0 5:1 +# 28:46 0:0 5:1 """resolve_model_for_role — env > spec primary. Purpose: every provider module asks one question on every call: @@ -6,9 +6,9 @@ I send to the API?" This module is the only allowed answer. Resolution order (highest precedence first): - 1. Env var `_MODEL_` (uppercase, e.g. CLAUDE_MODEL_CONDUCT, - OPENAI_MODEL_PERFORM, GROK_MODEL_PRACTICE). Env wins so an operator can - pin a model without touching DB or code. + 1. Env var derived from providers.json `model_env_prefix`, falling back to + the legacy built-in prefix map. Env wins so an operator can pin a model + without touching DB or code. 2. Provider spec primary (`BUILTIN_PROVIDERS[provider_id]["model"]`) which comes from python/config/providers.json. This is the doctrine baseline. @@ -67,15 +67,16 @@ async def resolve_model_for_role(provider_id: str, role: str) -> str: a non-empty model id for the (provider, role) pair. """ role_norm = role.lower().strip() - # 1. Env override - prefix = _PROVIDER_ENV_PREFIX.get(provider_id) + spec = BUILTIN_PROVIDERS.get(provider_id, {}) + # 1. Env override. Registry data lets new compatible providers opt in + # without changing this module; the map remains for legacy providers. + prefix = spec.get("model_env_prefix") or _PROVIDER_ENV_PREFIX.get(provider_id) if prefix: env_key = f"{prefix}{role_norm.upper()}" val = os.environ.get(env_key, "").strip() if val: return val # 2. Spec primary (providers.json baseline) - spec = BUILTIN_PROVIDERS.get(provider_id, {}) primary = (spec.get("model") or "").strip() if primary: return primary @@ -83,4 +84,4 @@ async def resolve_model_for_role(provider_id: str, role: str) -> str: f"No model resolvable for provider={provider_id!r} role={role_norm!r} " f"(checked env {prefix or '?'}{role_norm.upper()} and providers.json primary)" ) -# 28:45 0:0 5:1 +# 28:46 0:0 5:1 diff --git a/python/services/providers/openai_compatible_provider.py b/python/services/providers/openai_compatible_provider.py new file mode 100644 index 000000000..d6123940c --- /dev/null +++ b/python/services/providers/openai_compatible_provider.py @@ -0,0 +1,411 @@ +# 327:35 0:0 2:5 +"""Generic OpenAI-compatible provider transport. + +Provider identity, endpoint, credential name, model, API family, reasoning +scale, and tool profile are registry data. This module contains no +provider-specific endpoint or model literals. +""" +from __future__ import annotations + +# === MODULE_BUILD === +# id: a0_service_providers_openai_compatible +# module_name: openai_compatible_provider +# module_kind: adapter +# summary: Registry-driven OpenAI-compatible transport supporting Responses and Chat Completions with the shared repeat-safe tool loop. +# owner: Erin Spencer +# public_surface: call +# internal_surface: _call_responses, _call_chat_completions, _normalize_reasoning_effort, _response_tools +# auth_boundary: none +# storage_boundary: none +# network_boundary: external +# user_data_boundary: write +# admin_only: false +# tests: tests/test_openai_compatible_provider.py +# rollout: default_enabled +# rollback: Revert this module and remove registry entries whose adapter is openai-compatible. +# requires: a0_service_providers_resolver, a0_service_tool_executor, a0_service_tool_distill, a0_service_inference, a0_service_energy_registry +# since: 2026-09-07 +# unresolved: none +# === END MODULE_BUILD === + +# === CONTRACTS === +# id: openai_compatible_registry_driven +# given: a registered OpenAI-compatible provider id, messages, and optional model/key/effort overrides +# then: endpoint, model, credential name, API family, effort scale, and tool profile come from providers.json; missing explicit configuration fails closed and credentials do not enter error text +# class: correctness +# since: 2026-09-07 +# === END CONTRACTS === + +import copy +import json +import os +from typing import Any, Callable, Optional + +from openai import AsyncOpenAI + +from ._resolver import resolve_model_for_role + + +def _accumulate_usage(total: dict, usage: dict | None) -> None: + for key, value in (usage or {}).items(): + if isinstance(value, (int, float)): + total[key] = total.get(key, 0) + value + elif isinstance(value, dict): + nested = total.setdefault(key, {}) + if isinstance(nested, dict): + _accumulate_usage(nested, value) + + +def _safe_provider_error(provider_name: str, exc: BaseException, api_key: str) -> str: + from ..inference import _sanitize_provider_error + + outward = _sanitize_provider_error(provider_name, exc) + return outward.replace(api_key, "[redacted]") if api_key else outward + + +def _normalize_reasoning_effort(spec: dict, effort: Optional[str]) -> Optional[str]: + if not spec.get("supports_reasoning_effort"): + return None + requested = (effort or spec.get("default_reasoning_effort") or "medium").lower().strip() + mapped = (spec.get("reasoning_effort_map") or {}).get(requested, requested) + allowed = spec.get("reasoning_efforts") or [] + if allowed and mapped not in allowed: + return spec.get("default_reasoning_effort") or allowed[0] + return mapped + + +def _response_tools(tool_profile: str) -> list[dict]: + from ..tool_executor import get_active_chat_schemas, get_active_responses_schemas + + if tool_profile != "functions-only": + return get_active_responses_schemas() + result: list[dict] = [] + for schema in get_active_chat_schemas(): + function = schema.get("function") or {} + if not function.get("name"): + continue + result.append({ + "type": "function", + "name": function["name"], + "description": function.get("description", ""), + "parameters": function.get( + "parameters", {"type": "object", "properties": {}} + ), + }) + return result + + +def _format_responses_messages(messages: list[dict]) -> list[dict]: + formatted: list[dict] = [] + for message in copy.deepcopy(messages): + role = message.get("role", "user") + content = message.get("content", "") + if isinstance(content, list): + formatted.append({"role": role, "content": content}) + elif role in {"system", "assistant", "developer"}: + formatted.append({"role": role, "content": content}) + else: + formatted.append({ + "role": "user", + "content": [{"type": "input_text", "text": str(content)}], + }) + return formatted + + +def _responses_text(data: dict) -> str: + for item in data.get("output") or []: + if item.get("type") != "message": + continue + for part in item.get("content") or []: + if part.get("type") == "output_text" and part.get("text"): + return str(part["text"]) + return str(data.get("output_text") or "") + + +def _responses_kwargs( + *, + model: str, + input_items: list[dict], + max_output_tokens: int, + temperature: float, + reasoning_effort: Optional[str], + store: bool, + supports_store: bool, + tools: list[dict] | None, +) -> dict: + kwargs: dict[str, Any] = { + "model": model, + "input": input_items, + "temperature": temperature, + "max_output_tokens": max_output_tokens, + "text": {"format": {"type": "text"}}, + } + if supports_store: + kwargs["store"] = store + if reasoning_effort and reasoning_effort != "none": + kwargs["reasoning"] = {"effort": reasoning_effort} + if tools: + kwargs["tools"] = tools + return kwargs + + +async def _call_responses( + *, + api_key: str, + model: str, + input_messages: list[dict], + max_output_tokens: int, + temperature: float, + reasoning_effort: Optional[str], + store: bool, + use_tools: bool, + base_url: str | None = None, + provider_name: str = "openai-compatible", + tools_override: list[dict] | None = None, + supports_store: bool = False, +) -> tuple[str, dict]: + """Run the Responses API with a repeat-safe local function-tool loop.""" + from ..inference import ( + _canonical_tool_calls, + _get_max_tool_rounds, + ) + from ..tool_distill import set_caller_provider + from ..tool_executor import execute_tool + + set_caller_provider(provider_name) + input_items = _format_responses_messages(input_messages) + client_kwargs: dict[str, str] = {"api_key": api_key} + if base_url: + client_kwargs["base_url"] = base_url.rstrip("/") + client = AsyncOpenAI(**client_kwargs) + accumulated_usage: dict = {} + previous_fingerprint: Optional[str] = None + tools = tools_override if use_tools else None + + for round_index in range(_get_max_tool_rounds() + 1): + kwargs = _responses_kwargs( + model=model, + input_items=input_items, + max_output_tokens=max_output_tokens, + temperature=temperature, + reasoning_effort=reasoning_effort, + store=store, + supports_store=supports_store, + tools=tools, + ) + try: + response = await client.responses.create(**kwargs) + data = response.model_dump() + except Exception as exc: + return _safe_provider_error(provider_name, exc, api_key), accumulated_usage + + _accumulate_usage(accumulated_usage, data.get("usage")) + output_items = data.get("output") or [] + tool_calls = [item for item in output_items if item.get("type") == "function_call"] + + if tool_calls: + fingerprint = _canonical_tool_calls(tool_calls) + if fingerprint == previous_fingerprint: + return "[noticed repeat tool call — answering directly]", accumulated_usage + previous_fingerprint = fingerprint + + if not tool_calls or not use_tools or round_index >= _get_max_tool_rounds(): + content = _responses_text(data) + if content: + return content, accumulated_usage + input_items.append({ + "role": "user", + "content": [{"type": "input_text", "text": "Please provide your response."}], + }) + try: + nudge = await client.responses.create(**_responses_kwargs( + model=model, + input_items=input_items, + max_output_tokens=max_output_tokens, + temperature=temperature, + reasoning_effort=reasoning_effort, + store=store, + supports_store=supports_store, + tools=None, + )) + nudge_data = nudge.model_dump() + _accumulate_usage(accumulated_usage, nudge_data.get("usage")) + return _responses_text(nudge_data) or f"[{provider_name}: empty response]", accumulated_usage + except Exception as exc: + return _safe_provider_error(provider_name, exc, api_key), accumulated_usage + + input_items.extend(tool_calls) + for tool_call in tool_calls: + try: + arguments = json.loads(tool_call.get("arguments", "{}")) + except json.JSONDecodeError: + arguments = {} + result = await execute_tool(tool_call.get("name", ""), arguments) + input_items.append({ + "type": "function_call_output", + "call_id": tool_call.get("call_id", ""), + "output": result, + }) + + return f"[{provider_name}: tool loop exhausted]", accumulated_usage + + +def _chat_text(message: dict) -> str: + content = message.get("content") or "" + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + str(part.get("text", "")) for part in content if isinstance(part, dict) + ) + return str(content) + + +async def _call_chat_completions( + *, + api_key: str, + model: str, + messages: list[dict], + max_tokens: int, + temperature: float, + reasoning_effort: Optional[str], + use_tools: bool, + base_url: str | None, + provider_name: str, +) -> tuple[str, dict]: + """Run Chat Completions with the same repeat and tool-execution contract.""" + from ..inference import ( + _canonical_tool_calls, + _get_max_tool_rounds, + ) + from ..tool_distill import set_caller_provider + from ..tool_executor import execute_tool, get_active_chat_schemas + + set_caller_provider(provider_name) + client_kwargs: dict[str, str] = {"api_key": api_key} + if base_url: + client_kwargs["base_url"] = base_url.rstrip("/") + client = AsyncOpenAI(**client_kwargs) + conversation = copy.deepcopy(messages) + accumulated_usage: dict = {} + previous_fingerprint: Optional[str] = None + + for round_index in range(_get_max_tool_rounds() + 1): + kwargs: dict[str, Any] = { + "model": model, + "messages": conversation, + "max_tokens": max_tokens, + "temperature": temperature, + } + if reasoning_effort and reasoning_effort != "none": + kwargs["reasoning_effort"] = reasoning_effort + if use_tools: + kwargs["tools"] = get_active_chat_schemas() + try: + response = await client.chat.completions.create(**kwargs) + data = response.model_dump() + except Exception as exc: + return _safe_provider_error(provider_name, exc, api_key), accumulated_usage + + _accumulate_usage(accumulated_usage, data.get("usage")) + choices = data.get("choices") or [] + message = (choices[0].get("message") if choices else None) or {} + tool_calls = message.get("tool_calls") or [] + if tool_calls: + fingerprint = _canonical_tool_calls(tool_calls) + if fingerprint == previous_fingerprint: + return "[noticed repeat tool call — answering directly]", accumulated_usage + previous_fingerprint = fingerprint + + if not tool_calls or not use_tools or round_index >= _get_max_tool_rounds(): + return _chat_text(message) or f"[{provider_name}: empty response]", accumulated_usage + + conversation.append({ + "role": "assistant", + "content": message.get("content"), + "tool_calls": tool_calls, + }) + for tool_call in tool_calls: + function = tool_call.get("function") or {} + try: + arguments = json.loads(function.get("arguments", "{}")) + except json.JSONDecodeError: + arguments = {} + result = await execute_tool(function.get("name", ""), arguments) + conversation.append({ + "role": "tool", + "tool_call_id": tool_call.get("id", ""), + "content": result, + }) + + return f"[{provider_name}: tool loop exhausted]", accumulated_usage + + +async def call( + messages: list[dict], + *, + provider_id: str, + role: str = "conduct", + model_override: Optional[str] = None, + api_key: Optional[str] = None, + max_tokens: int = 4096, + use_tools: bool = True, + reasoning_effort: Optional[str] = None, + temperature: float = 1.0, + store: bool = False, + progress_callback: Optional[Callable[[int, int], None]] = None, +) -> tuple[str, dict]: + """Dispatch one registry-defined provider through its configured API family.""" + del progress_callback # Reserved for a future streaming implementation. + from ..energy_registry import BUILTIN_PROVIDERS + + spec = BUILTIN_PROVIDERS.get(provider_id) + if not spec: + raise ValueError(f"Unknown provider_id: {provider_id!r}") + if spec.get("adapter") != "openai-compatible" and spec.get("vendor") != "openai": + raise ValueError(f"Provider {provider_id!r} is not OpenAI-compatible") + + api_key_env = str(spec.get("api_key_env") or "").strip() + if not api_key_env: + raise ValueError(f"Provider {provider_id!r} has no api_key_env") + key = (api_key or os.environ.get(api_key_env, "")).strip() + if not key: + raise ValueError(f"{api_key_env} not configured") + + model = model_override or await resolve_model_for_role(provider_id, role) + base_url = str(spec.get("base_url") or "").strip() or None + effort = _normalize_reasoning_effort(spec, reasoning_effort) + api_family = spec.get("api_family", "responses") + + if api_family == "responses": + tools = _response_tools(spec.get("tool_profile", "all-responses")) if use_tools else None + return await _call_responses( + api_key=key, + model=model, + input_messages=messages, + max_output_tokens=max_tokens, + temperature=temperature, + reasoning_effort=effort, + store=store, + use_tools=use_tools, + base_url=base_url, + provider_name=provider_id, + tools_override=tools, + supports_store=bool(spec.get("supports_store", spec.get("vendor") == "openai")), + ) + if api_family == "chat_completions": + return await _call_chat_completions( + api_key=key, + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + reasoning_effort=effort, + use_tools=use_tools, + base_url=base_url, + provider_name=provider_id, + ) + raise ValueError( + f"Provider {provider_id!r} has unsupported api_family={api_family!r}" + ) +# 327:35 0:0 2:5 diff --git a/python/services/providers/openai_provider.py b/python/services/providers/openai_provider.py index 6b35ce0d1..33d2d6edd 100644 --- a/python/services/providers/openai_provider.py +++ b/python/services/providers/openai_provider.py @@ -1,25 +1,12 @@ -# 160:42 0:0 1:4 -"""openai_provider — OpenAI GPT-5 family via the Responses API. - -Migrated from raw httpx to the `openai` Python SDK (v2). The contract is -unchanged; the httpx block is replaced by `AsyncOpenAI.responses.create()`, -which gives us built-in retries, proper error typing, and OpenAI tracing. - - call(messages, *, role, model_override, api_key, - max_tokens, use_tools, reasoning_effort, ...) - -> (content, usage) - -The tool loop structure is identical to the pre-SDK version; only the outbound -HTTP call changes. `response.model_dump()` converts the SDK object to the same -dict shape the tool-loop code already understood, so zero churn downstream. -""" +# 28:22 0:0 1:1 +"""Compatibility wrapper for the registry-driven OpenAI provider.""" from __future__ import annotations # === MODULE_BUILD === # id: a0_service_providers_openai # module_name: openai_provider # module_kind: adapter -# summary: OpenAI GPT-5-family provider adapter using the Responses API via the openai SDK — exposes the standard async call(...) -> (content, usage) with the shared tool-loop contract. +# summary: Stable OpenAI-specific call surface delegating transport behavior to the generic OpenAI-compatible adapter. # owner: Erin Spencer # public_surface: call # internal_surface: _call_responses @@ -28,22 +15,18 @@ # network_boundary: external # user_data_boundary: write # admin_only: false -# tests: hmmm +# tests: tests/test_openai_compatible_provider.py # rollout: default_enabled -# rollback: Revert this file; OpenAI calls revert to the prior httpx-based implementation. -# requires: a0_service_providers_resolver, a0_service_tool_executor, a0_service_tool_distill, a0_service_inference +# rollback: Restore the former OpenAI-only Responses implementation. +# requires: a0_service_providers_openai_compatible # since: 2026-06-02 # unresolved: none # === END MODULE_BUILD === -import copy -import json -import os from typing import Optional -from openai import AsyncOpenAI - -from ._resolver import resolve_model_for_role +from .openai_compatible_provider import _call_responses +from .openai_compatible_provider import call as _compatible_call async def call( @@ -58,172 +41,17 @@ async def call( temperature: float = 1.0, store: bool = False, ) -> tuple[str, dict]: - """Run a chat turn against OpenAI's Responses API.""" - key = api_key or os.environ.get("OPENAI_API_KEY", "").strip() - if not key: - raise ValueError("OPENAI_API_KEY not configured") - model = model_override or await resolve_model_for_role("openai", role) - - return await _call_responses( - api_key=key, - model=model, - input_messages=messages, - max_output_tokens=max_tokens, + """Run the built-in OpenAI provider through the shared transport.""" + return await _compatible_call( + messages, + provider_id="openai", + role=role, + model_override=model_override, + api_key=api_key, + max_tokens=max_tokens, + use_tools=use_tools, + reasoning_effort=reasoning_effort, temperature=temperature, - reasoning_effort=reasoning_effort or "medium", store=store, - use_tools=use_tools, - ) - - -async def _call_responses( - api_key: str, - model: str, - input_messages: list[dict], - max_output_tokens: int, - temperature: float, - reasoning_effort: str, - store: bool, - use_tools: bool, -) -> tuple[str, dict]: - """Tool loop over the OpenAI Responses API via the native SDK. - - The SDK replaces the raw httpx POST; `response.model_dump()` converts the - typed response to a plain dict so the rest of the loop is unchanged. - Up to _MAX_TOOL_ROUNDS rounds; repeat-call short-circuit prevents infinite - loops; only `function_call` items are echoed back per Responses API rules. - """ - from ..tool_distill import set_caller_provider - from ..tool_executor import get_active_responses_schemas, execute_tool - from ..inference import ( - _get_max_tool_rounds, - _canonical_tool_calls, - _sanitize_provider_error, ) - - set_caller_provider("openai") - - def _fmt_messages(msgs: list[dict]) -> list[dict]: - out: list[dict] = [] - for m in msgs: - r = m.get("role", "user") - content = m.get("content", "") - if r == "system": - out.append({"role": "system", "content": content}) - elif r == "assistant": - out.append({"role": "assistant", "content": content}) - else: - out.append({ - "role": "user", - "content": [{"type": "input_text", "text": content}], - }) - return out - - openai_input = _fmt_messages(copy.deepcopy(input_messages)) - oai_client = AsyncOpenAI(api_key=api_key) - accumulated_usage: dict = {} - prev_call_fingerprint: Optional[str] = None - - for _round in range(_get_max_tool_rounds() + 1): - kwargs: dict = { - "model": model, - "input": openai_input, - "store": store, - "temperature": temperature, - "max_output_tokens": max_output_tokens, - "text": {"format": {"type": "text"}}, - } - if reasoning_effort and reasoning_effort != "none": - kwargs["reasoning"] = {"effort": reasoning_effort} - if use_tools: - kwargs["tools"] = get_active_responses_schemas() - - print(f"[oai-dbg] round={_round} input_len={len(openai_input)} roles={[m.get('role','?') for m in openai_input if isinstance(m, dict) and 'role' in m]}") - try: - response = await oai_client.responses.create(**kwargs) - data = response.model_dump() - except Exception as exc: - return _sanitize_provider_error("openai", exc), accumulated_usage - - for k, v in (data.get("usage") or {}).items(): - if isinstance(v, (int, float)): - accumulated_usage[k] = accumulated_usage.get(k, 0) + v - - output_items = data.get("output") or [] - print(f"[oai-dbg] output item types={[it.get('type') for it in output_items]}") - tool_calls = [it for it in output_items if it.get("type") == "function_call"] - - if tool_calls: - fp = _canonical_tool_calls(tool_calls) - if prev_call_fingerprint is not None and fp == prev_call_fingerprint: - return "[noticed repeat tool call — answering directly]", accumulated_usage - prev_call_fingerprint = fp - - if not tool_calls or not use_tools or _round >= _get_max_tool_rounds(): - content = "" - for item in output_items: - if item.get("type") == "message": - for part in item.get("content") or []: - if part.get("type") == "output_text": - content = part.get("text", "") - break - if content: - break - if content: - return content, accumulated_usage - # GPT-5 produced no message item — either the tool loop exhausted - # without a final answer or reasoning ran without emitting one. - # One explicit nudge to surface the response. - openai_input.append({ - "role": "user", - "content": [{"type": "input_text", "text": "Please provide your response."}], - }) - try: - _nudge_kw: dict = { - "model": model, - "input": openai_input, - "store": store, - "temperature": temperature, - "max_output_tokens": max_output_tokens, - "text": {"format": {"type": "text"}}, - } - if reasoning_effort and reasoning_effort != "none": - _nudge_kw["reasoning"] = {"effort": reasoning_effort} - _nr = await oai_client.responses.create(**_nudge_kw) - _nd = _nr.model_dump() - for k, v in (_nd.get("usage") or {}).items(): - if isinstance(v, (int, float)): - accumulated_usage[k] = accumulated_usage.get(k, 0) + v - for item in (_nd.get("output") or []): - if item.get("type") == "message": - for part in item.get("content") or []: - if part.get("type") == "output_text": - content = part.get("text", "") - break - if content: - break - except Exception: - pass - return content or "[openai: empty response]", accumulated_usage - - # Multi-turn rule: only function_call items in next round's input - for item in output_items: - if item.get("type") == "function_call": - openai_input.append(item) - - for tc in tool_calls: - call_id = tc.get("call_id", "") - name = tc.get("name", "") - try: - args = json.loads(tc.get("arguments", "{}")) - except json.JSONDecodeError: - args = {} - result = await execute_tool(name, args) - openai_input.append({ - "type": "function_call_output", - "call_id": call_id, - "output": result, - }) - - return "[openai: tool loop exhausted]", accumulated_usage -# 160:42 0:0 1:4 +# 28:22 0:0 1:1 diff --git a/python/services/providers/xai_provider.py b/python/services/providers/xai_provider.py index 413e3ca8e..d2c6dabb0 100644 --- a/python/services/providers/xai_provider.py +++ b/python/services/providers/xai_provider.py @@ -100,7 +100,7 @@ async def call( from ..energy_registry import BUILTIN_PROVIDERS spec = BUILTIN_PROVIDERS.get("grok", {}) - key = api_key or os.environ.get(spec.get("env_key", "XAI_API_KEY"), "").strip() + key = api_key or os.environ.get(spec.get("api_key_env", "XAI_API_KEY"), "").strip() if not key: raise ValueError("XAI_API_KEY not configured") model = model_override or await resolve_model_for_role("grok", role) diff --git a/python/tests/test_openai_compatible_contracts.py b/python/tests/test_openai_compatible_contracts.py new file mode 100644 index 000000000..f58f1ee71 --- /dev/null +++ b/python/tests/test_openai_compatible_contracts.py @@ -0,0 +1,94 @@ +# 63:10 0:0 0:0 +"""Executable msdmd witness for the generic provider boundary.""" + +# === CHECKS === +# id: check_openai_compatible_registry_wiring +# proves: openai_compatible_registry_driven, a0_provider_selection, a0_openai_compatible_completion +# call: self::check_openai_compatible_registry_wiring +# requires: python3 +# timeout: 20 +# mutates: none +# cleanup: none +# === END CHECKS === + +import asyncio +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + + +class _Response: + output_text = "witness-ok" + + def model_dump(self) -> dict: + return { + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "witness-ok"}], + }], + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + + +def check_openai_compatible_registry_wiring() -> None: + from a0.adapters import openai_compatible_adapter as standalone_adapter + from a0.provider_registry import resolve_openai_compatible_provider + from python.services.providers import openai_compatible_provider as service_adapter + + root = Path(__file__).resolve().parents[2] + registry_text = (root / "python/config/providers.json").read_text(encoding="utf-8") + assert "deepseek-v4-flash" in registry_text + assert "deepseek-v4-pro" in registry_text + assert "deepseek-chat" not in registry_text + assert "deepseek-reasoner" not in registry_text + assert not (root / "python/services/providers/deepseek_provider.py").exists() + + captured: dict = {} + + class FakeSyncOpenAI: + def __init__(self, **kwargs): + captured["sync_client"] = kwargs + self.responses = SimpleNamespace(create=lambda **request: _Response()) + + class FakeAsyncOpenAI: + def __init__(self, **kwargs): + captured["async_client"] = kwargs + + async def create(**request): + captured["async_request"] = request + return _Response() + + self.responses = SimpleNamespace(create=create) + + environment = {"DEEPSEEK_API_KEY": "witness-secret", "A0_PROVIDER": "deepseek-pro"} + with patch.dict(os.environ, environment, clear=False): + provider_id, spec = resolve_openai_compatible_provider() + assert provider_id == "deepseek-pro" + with patch.object(standalone_adapter, "OpenAI", FakeSyncOpenAI): + result = standalone_adapter.OpenAICompatibleAdapter(provider_id, spec).complete( + [{"role": "user", "content": "hi"}] + ) + assert result["text"] == "witness-ok" + assert "witness-secret" not in repr(result) + + async def run_service_call(): + return await service_adapter.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek-pro", + use_tools=False, + ) + + with patch.object(service_adapter, "AsyncOpenAI", FakeAsyncOpenAI): + content, usage = asyncio.run(run_service_call()) + + assert content == "witness-ok" + assert usage["output_tokens"] == 1 + assert captured["sync_client"]["base_url"] == "https://api.deepseek.com" + assert captured["async_client"]["base_url"] == "https://api.deepseek.com" + assert captured["async_request"]["model"] == "deepseek-v4-pro" + + +def test_openai_compatible_registry_wiring() -> None: + check_openai_compatible_registry_wiring() +# 63:10 0:0 0:0 diff --git a/repl_nix_workspace.egg-info/SOURCES.txt b/repl_nix_workspace.egg-info/SOURCES.txt index 7a9a8e4ef..96ca2178e 100644 --- a/repl_nix_workspace.egg-info/SOURCES.txt +++ b/repl_nix_workspace.egg-info/SOURCES.txt @@ -113,6 +113,7 @@ python/services/providers/__init__.py python/services/providers/_resolver.py python/services/providers/claude_provider.py python/services/providers/gemini_provider.py +python/services/providers/openai_compatible_provider.py python/services/providers/openai_provider.py python/services/providers/xai_provider.py python/services/tools/__init__.py @@ -165,6 +166,7 @@ repl_nix_workspace.egg-info/SOURCES.txt repl_nix_workspace.egg-info/dependency_links.txt repl_nix_workspace.egg-info/requires.txt repl_nix_workspace.egg-info/top_level.txt +tests/test_a0_openai_compatible_adapter.py tests/test_a0_package.py tests/test_artifacts.py tests/test_compute_transcript_full.py @@ -174,6 +176,7 @@ tests/test_hmmm_boundary.py tests/test_inference_modes_usage.py tests/test_interdependent_bootstrap.py tests/test_live_server.py +tests/test_openai_compatible_provider.py tests/test_openai_policy_hmmm.py tests/test_route_imports.py tests/test_run_context.py diff --git a/suggest.md b/suggest.md index ee30cd4a8..3996dbc32 100644 --- a/suggest.md +++ b/suggest.md @@ -141,27 +141,14 @@ Update `OutputEnvelope.gaming_alerts` type and the JSON schema in `io/schemas.py ## P2 — Completeness -### P2-1: Implement OpenAI and Gemini adapters +### P2-1: Implement Gemini adapter -**Files:** `a0/a0/adapters/openai_adapter.py`, `a0/a0/adapters/gemini_adapter.py` +**File:** `a0/adapters/gemini_adapter.py` -**Problem:** Both files are empty. The adapter `Protocol` in `model_adapter.py` defines the interface. Until real adapters are implemented, a0 is limited to echoing input. +**Problem:** The file is empty. The adapter `Protocol` in `model_adapter.py` defines the interface. OpenAI-compatible providers are now implemented through `openai_compatible_adapter.py`; Gemini still lacks a native standalone adapter. -**Minimum viable implementation for OpenAI adapter:** -```python -from openai import OpenAI - -class OpenAIAdapter: - name = "openai" - def __init__(self, model="gpt-4o-mini"): - self.client = OpenAI() - self.model = model - def complete(self, messages): - r = self.client.chat.completions.create(model=self.model, messages=messages) - return {"text": r.choices[0].message.content} -``` - -Update `a0/router.py` to select adapters from an environment variable or config rather than always using `LocalEchoAdapter()`. +**Minimum viable implementation:** add a Gemini `ModelAdapter`, then extend the +registry-driven selection without reintroducing hard-coded provider branches. --- diff --git a/tests/test_a0_openai_compatible_adapter.py b/tests/test_a0_openai_compatible_adapter.py new file mode 100644 index 000000000..b506174ec --- /dev/null +++ b/tests/test_a0_openai_compatible_adapter.py @@ -0,0 +1,116 @@ +# 82:1 0:0 0:0 +"""Standalone a0 selection and OpenAI-compatible adapter contracts.""" + +from types import SimpleNamespace + +import pytest + +from a0.contract import A0Request + + +class _Dump: + output_text = "standalone-ok" + + def model_dump(self) -> dict: + return {"output": [], "usage": {"input_tokens": 1, "output_tokens": 1}} + + +def test_registry_auto_selects_flash_and_explicitly_selects_pro( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from a0.provider_registry import resolve_openai_compatible_provider + + monkeypatch.delenv("A0_PROVIDER", raising=False) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + provider_id, spec = resolve_openai_compatible_provider() + assert provider_id == "deepseek" + assert spec["model"] == "deepseek-v4-flash" + + monkeypatch.setenv("A0_PROVIDER", "deepseek-pro") + provider_id, spec = resolve_openai_compatible_provider() + assert provider_id == "deepseek-pro" + assert spec["model"] == "deepseek-v4-pro" + + +def test_explicit_unknown_and_noncompatible_provider_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from a0.provider_registry import resolve_openai_compatible_provider + + monkeypatch.setenv("A0_PROVIDER", "missing-provider") + with pytest.raises(ValueError, match="Unknown A0_PROVIDER"): + resolve_openai_compatible_provider() + + monkeypatch.setenv("A0_PROVIDER", "claude") + with pytest.raises(ValueError, match="does not use the openai-compatible adapter"): + resolve_openai_compatible_provider() + + +def test_adapter_uses_registry_transport_and_sanitizes_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from a0.adapters import openai_compatible_adapter as adapter_module + from a0.provider_registry import resolve_openai_compatible_provider + + captured: dict = {} + + class FakeOpenAI: + def __init__(self, **kwargs): + captured["client"] = kwargs + + def create(**request): + captured["request"] = request + return _Dump() + + self.responses = SimpleNamespace(create=create) + + monkeypatch.setattr(adapter_module, "OpenAI", FakeOpenAI) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setenv("A0_PROVIDER", "deepseek-pro") + provider_id, spec = resolve_openai_compatible_provider() + adapter = adapter_module.OpenAICompatibleAdapter(provider_id, spec) + result = adapter.complete([{"role": "user", "content": "hi"}]) + + assert result["text"] == "standalone-ok" + assert result["raw"]["provider"] == "deepseek-pro" + assert "test-secret" not in repr(result) + assert captured["client"] == { + "api_key": "test-secret", + "base_url": "https://api.deepseek.com", + } + assert captured["request"]["model"] == "deepseek-v4-pro" + assert captured["request"]["reasoning"] == {"effort": "high"} + + +def test_router_prefers_configured_generic_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from a0.adapters import openai_compatible_adapter as adapter_module + from a0.router import _select_adapter + + class FakeOpenAI: + def __init__(self, **kwargs): + self.responses = SimpleNamespace(create=lambda **request: _Dump()) + + monkeypatch.setattr(adapter_module, "OpenAI", FakeOpenAI) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.delenv("A0_PROVIDER", raising=False) + request = A0Request(task_id="adapter-select", input={"text": "hi"}) + + selected = _select_adapter(request) + assert isinstance(selected, adapter_module.OpenAICompatibleAdapter) + assert selected.provider_id == "deepseek" + + +def test_explicit_provider_missing_key_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from a0.router import _select_adapter + + monkeypatch.setenv("A0_PROVIDER", "deepseek-pro") + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + request = A0Request(task_id="missing-key", input={"text": "hi"}) + + with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): + _select_adapter(request) +# 82:1 0:0 0:0 diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py new file mode 100644 index 000000000..11f771ef5 --- /dev/null +++ b/tests/test_openai_compatible_provider.py @@ -0,0 +1,252 @@ +# 196:1 0:0 0:0 +"""Contract tests for registry-driven OpenAI-compatible providers.""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +class _Dump: + def __init__(self, data: dict, output_text: str = "") -> None: + self._data = data + self.output_text = output_text + + def model_dump(self) -> dict: + return self._data + + +def _clear_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None: + from python.services.energy_registry import BUILTIN_PROVIDERS + + for spec in BUILTIN_PROVIDERS.values(): + api_key_env = spec.get("api_key_env") + if api_key_env: + monkeypatch.delenv(api_key_env, raising=False) + + +def test_deepseek_is_configuration_not_a_provider_specific_adapter() -> None: + from python.services.energy_registry import BUILTIN_PROVIDERS + + root = Path(__file__).resolve().parents[1] + assert not (root / "python/services/providers/deepseek_provider.py").exists() + + flash = BUILTIN_PROVIDERS["deepseek"] + pro = BUILTIN_PROVIDERS["deepseek-pro"] + assert flash["adapter"] == pro["adapter"] == "openai-compatible" + assert flash["base_url"] == pro["base_url"] == "https://api.deepseek.com" + assert flash["api_key_env"] == pro["api_key_env"] == "DEEPSEEK_API_KEY" + assert flash["model"] == "deepseek-v4-flash" + assert pro["model"] == "deepseek-v4-pro" + + registry_text = (root / "python/config/providers.json").read_text(encoding="utf-8") + assert "deepseek-chat" not in registry_text + assert "deepseek-reasoner" not in registry_text + + +@pytest.mark.asyncio +async def test_responses_transport_uses_registry_base_url_model_and_effort( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.providers import openai_compatible_provider as provider + + captured: dict = {"requests": []} + + class FakeAsyncOpenAI: + def __init__(self, **kwargs): + captured["client"] = kwargs + + async def create(**request): + captured["requests"].append(request) + return _Dump({ + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "ok"}], + }], + "usage": {"input_tokens": 2, "output_tokens": 1}, + }) + + self.responses = SimpleNamespace(create=create) + + monkeypatch.setattr(provider, "AsyncOpenAI", FakeAsyncOpenAI) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + + content, usage = await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek-pro", + use_tools=False, + reasoning_effort="medium", + ) + + assert content == "ok" + assert usage == {"input_tokens": 2, "output_tokens": 1} + assert captured["client"] == { + "api_key": "test-secret", + "base_url": "https://api.deepseek.com", + } + request = captured["requests"][0] + assert request["model"] == "deepseek-v4-pro" + assert request["reasoning"] == {"effort": "high"} + assert "store" not in request + assert "tools" not in request + + +@pytest.mark.asyncio +async def test_generic_transport_supports_chat_completions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.energy_registry import BUILTIN_PROVIDERS + from python.services.providers import openai_compatible_provider as provider + + captured: dict = {} + + class FakeAsyncOpenAI: + def __init__(self, **kwargs): + captured["client"] = kwargs + + async def create(**request): + captured["request"] = request + return _Dump({ + "choices": [{"message": {"content": "chat-ok"}}], + "usage": {"prompt_tokens": 2, "completion_tokens": 1}, + }) + + self.chat = SimpleNamespace(completions=SimpleNamespace(create=create)) + + spec = { + "id": "compatible-chat-test", + "model": "compatible-model", + "adapter": "openai-compatible", + "vendor": "openai-compatible", + "base_url": "https://compatible.invalid/v1", + "api_key_env": "COMPATIBLE_TEST_KEY", + "api_family": "chat_completions", + "supports_reasoning_effort": True, + "reasoning_efforts": ["low", "high"], + "default_reasoning_effort": "low", + } + monkeypatch.setitem(BUILTIN_PROVIDERS, spec["id"], spec) + monkeypatch.setattr(provider, "AsyncOpenAI", FakeAsyncOpenAI) + monkeypatch.setenv("COMPATIBLE_TEST_KEY", "test-secret") + + content, usage = await provider.call( + [{"role": "user", "content": "hi"}], + provider_id=spec["id"], + use_tools=False, + ) + + assert content == "chat-ok" + assert usage["completion_tokens"] == 1 + assert captured["client"]["base_url"] == "https://compatible.invalid/v1" + assert captured["request"]["model"] == "compatible-model" + assert captured["request"]["reasoning_effort"] == "low" + + +@pytest.mark.asyncio +async def test_transport_redacts_configured_key_from_outward_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.providers import openai_compatible_provider as provider + + class FailingAsyncOpenAI: + def __init__(self, **kwargs): + async def create(**request): + raise RuntimeError("upstream echoed witness-secret") + + self.responses = SimpleNamespace(create=create) + + monkeypatch.setattr(provider, "AsyncOpenAI", FailingAsyncOpenAI) + monkeypatch.setenv("DEEPSEEK_API_KEY", "witness-secret") + content, usage = await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek", + use_tools=False, + ) + + assert "witness-secret" not in content + assert "[redacted]" in content + assert usage == {} + + +@pytest.mark.asyncio +async def test_openai_wrapper_preserves_reasoning_and_store_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.providers import openai_provider + + captured: dict = {} + + async def fake_compatible_call(messages, **kwargs): + captured.update(kwargs) + return "openai-ok", {} + + monkeypatch.setattr(openai_provider, "_compatible_call", fake_compatible_call) + content, usage = await openai_provider.call( + [{"role": "user", "content": "hi"}], + api_key="test-openai", + model_override="gpt-test", + reasoning_effort="high", + store=True, + ) + + assert (content, usage) == ("openai-ok", {}) + assert captured["provider_id"] == "openai" + assert captured["reasoning_effort"] == "high" + assert captured["store"] is True + + +@pytest.mark.asyncio +async def test_inference_dispatches_adapter_field_without_database( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import inference + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.delenv("DATABASE_URL", raising=False) + captured: dict = {} + + async def fake_call(messages, **kwargs): + captured.update(kwargs) + return "routed", {"total_tokens": 1} + + monkeypatch.setattr(provider, "call", fake_call) + content, usage = await inference.call_provider( + "deepseek", + [{"role": "user", "content": "hi"}], + max_tokens=8, + use_tools=False, + skip_manifest=True, + ) + + assert content == "routed" + assert usage == {"total_tokens": 1} + assert captured["provider_id"] == "deepseek" + assert captured["model_override"] == "deepseek-v4-flash" + + +@pytest.mark.asyncio +async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.energy_registry import estimate_cost, get_model_pricing + from python.services.model_catalog import resolve_model_id + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + provider_id, spec = await resolve_model_id("deepseek-v4-pro") + assert provider_id == "deepseek-pro" + assert spec["model"] == "deepseek-v4-pro" + assert get_model_pricing("deepseek", "deepseek-v4-flash")["input_per_1m"] == 0.44 + assert estimate_cost( + "deepseek", 1_000_000, 1_000_000, model="deepseek-v4-flash" + ) == pytest.approx(1.76) + + with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): + await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek", + use_tools=False, + ) +# 196:1 0:0 0:0 From dd8ab62238a3c2be323ec83a7e4fc584c299bfa3 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Tue, 8 Sep 2026 05:07:08 -0700 Subject: [PATCH 02/48] chore(provider): remove obsolete transport import --- python/services/providers/openai_provider.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/services/providers/openai_provider.py b/python/services/providers/openai_provider.py index 33d2d6edd..bcdaec899 100644 --- a/python/services/providers/openai_provider.py +++ b/python/services/providers/openai_provider.py @@ -9,7 +9,7 @@ # summary: Stable OpenAI-specific call surface delegating transport behavior to the generic OpenAI-compatible adapter. # owner: Erin Spencer # public_surface: call -# internal_surface: _call_responses +# internal_surface: none # auth_boundary: none # storage_boundary: none # network_boundary: external @@ -25,7 +25,6 @@ from typing import Optional -from .openai_compatible_provider import _call_responses from .openai_compatible_provider import call as _compatible_call From 9488c2f2b385284ebe50614ee3fa5e1d4da54c5e Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Tue, 8 Sep 2026 05:12:23 -0700 Subject: [PATCH 03/48] fix(provider): close compatible routing review gaps --- .../services/providers/openai_compatible_provider.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/services/providers/openai_compatible_provider.py b/python/services/providers/openai_compatible_provider.py index d6123940c..4c37b29c5 100644 --- a/python/services/providers/openai_compatible_provider.py +++ b/python/services/providers/openai_compatible_provider.py @@ -57,10 +57,10 @@ def _accumulate_usage(total: dict, usage: dict | None) -> None: def _safe_provider_error(provider_name: str, exc: BaseException, api_key: str) -> str: - from ..inference import _sanitize_provider_error + from ..inference import _safe_error_snippet - outward = _sanitize_provider_error(provider_name, exc) - return outward.replace(api_key, "[redacted]") if api_key else outward + raw = str(exc).replace(api_key, "[redacted]") if api_key else str(exc) + return f"[{provider_name} error: {type(exc).__name__}: {safe}]" if (safe := _safe_error_snippet(raw)) else f"[{provider_name} error: {type(exc).__name__}]" def _normalize_reasoning_effort(spec: dict, effort: Optional[str]) -> Optional[str]: @@ -204,7 +204,7 @@ async def _call_responses( tool_calls = [item for item in output_items if item.get("type") == "function_call"] if tool_calls: - fingerprint = _canonical_tool_calls(tool_calls) + fingerprint = _canonical_tool_calls(tool_calls) or json.dumps(tool_calls, sort_keys=True, default=str) if fingerprint == previous_fingerprint: return "[noticed repeat tool call — answering directly]", accumulated_usage previous_fingerprint = fingerprint @@ -312,7 +312,7 @@ async def _call_chat_completions( message = (choices[0].get("message") if choices else None) or {} tool_calls = message.get("tool_calls") or [] if tool_calls: - fingerprint = _canonical_tool_calls(tool_calls) + fingerprint = _canonical_tool_calls(tool_calls) or json.dumps(tool_calls, sort_keys=True, default=str) if fingerprint == previous_fingerprint: return "[noticed repeat tool call — answering directly]", accumulated_usage previous_fingerprint = fingerprint @@ -372,7 +372,7 @@ async def call( if not key: raise ValueError(f"{api_key_env} not configured") - model = model_override or await resolve_model_for_role(provider_id, role) + model = await resolve_model_for_role(provider_id, role) if model_override is None or model_override == spec.get("model") else model_override base_url = str(spec.get("base_url") or "").strip() or None effort = _normalize_reasoning_effort(spec, reasoning_effort) api_family = spec.get("api_family", "responses") From 6db4f6e478d4775d0a11e29aaa6cd6ef8952f434 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Tue, 8 Sep 2026 05:13:03 -0700 Subject: [PATCH 04/48] fix(catalog): enforce resolved model tier on presets --- python/services/model_catalog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/services/model_catalog.py b/python/services/model_catalog.py index 6f11379a7..474d2ea07 100644 --- a/python/services/model_catalog.py +++ b/python/services/model_catalog.py @@ -205,6 +205,9 @@ def _touch(mid: str) -> dict: continue for mid in role_map.values(): if isinstance(mid, str) and mid: + owner = _resolve_static(mid) + if owner and owner[0] != pid and not _tier_ok(user_tier, owner[1].get("min_tier")): + continue e = _touch(mid) if preset_name not in e["in_presets"]: e["in_presets"].append(preset_name) From 9a3a8b71db2623c10e1a9009de8266dada5f2485 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Tue, 8 Sep 2026 05:14:20 -0700 Subject: [PATCH 05/48] test(provider): cover exact review regressions --- tests/test_openai_compatible_provider.py | 144 +++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index 11f771ef5..24239e301 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -91,6 +91,98 @@ async def create(**request): assert "tools" not in request +@pytest.mark.asyncio +async def test_dispatcher_primary_placeholder_still_honors_model_env_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.providers import openai_compatible_provider as provider + + captured: dict = {} + + class FakeAsyncOpenAI: + def __init__(self, **kwargs): + async def create(**request): + captured.update(request) + return _Dump({ + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "override-ok"}], + }], + "usage": {}, + }) + + self.responses = SimpleNamespace(create=create) + + monkeypatch.setattr(provider, "AsyncOpenAI", FakeAsyncOpenAI) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setenv("DEEPSEEK_MODEL_CONDUCT", "deepseek-v4-flash-override") + + content, _ = await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek", + role="conduct", + model_override="deepseek-v4-flash", + use_tools=False, + ) + + assert content == "override-ok" + assert captured["model"] == "deepseek-v4-flash-override" + + +@pytest.mark.asyncio +async def test_first_responses_tool_call_executes_before_repeat_detection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import tool_executor + from python.services.providers import openai_compatible_provider as provider + + responses: list[dict] = [] + executed: list[tuple[str, dict]] = [] + + class FakeAsyncOpenAI: + def __init__(self, **kwargs): + async def create(**request): + responses.append(request) + if len(responses) == 1: + return _Dump({ + "output": [{ + "type": "function_call", + "name": "sys.pwd", + "arguments": "{}", + "call_id": "call-1", + }], + "usage": {}, + }) + return _Dump({ + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "tool-ok"}], + }], + "usage": {}, + }) + + self.responses = SimpleNamespace(create=create) + + async def fake_execute(name: str, arguments: dict) -> str: + executed.append((name, arguments)) + return "pwd-ok" + + monkeypatch.setattr(provider, "AsyncOpenAI", FakeAsyncOpenAI) + monkeypatch.setattr(provider, "_response_tools", lambda profile: [{"type": "function", "name": "sys.pwd"}]) + monkeypatch.setattr(tool_executor, "execute_tool", fake_execute) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + + content, _ = await provider.call( + [{"role": "user", "content": "use a tool"}], + provider_id="deepseek", + use_tools=True, + ) + + assert content == "tool-ok" + assert executed == [("sys.pwd", {})] + assert len(responses) == 2 + + @pytest.mark.asyncio async def test_generic_transport_supports_chat_completions( monkeypatch: pytest.MonkeyPatch, @@ -168,6 +260,58 @@ async def create(**request): assert usage == {} +@pytest.mark.asyncio +async def test_transport_redacts_opaque_key_before_error_truncation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.providers import openai_compatible_provider as provider + + api_key = "opaque-provider-key-abcdefghijklmnopqrstuvwxyz" + + class FailingAsyncOpenAI: + def __init__(self, **kwargs): + async def create(**request): + raise RuntimeError("x" * 180 + api_key + " tail") + + self.responses = SimpleNamespace(create=create) + + monkeypatch.setattr(provider, "AsyncOpenAI", FailingAsyncOpenAI) + monkeypatch.setenv("DEEPSEEK_API_KEY", api_key) + content, _ = await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek", + use_tools=False, + ) + + assert api_key not in content + assert api_key[:20] not in content + assert "[redacted]" in content + + +@pytest.mark.asyncio +async def test_free_catalog_does_not_surface_cross_tier_deepseek_pro( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import energy_registry, model_catalog + + async def free_tier(user_id): + return "free" + + async def active_provider(): + return "deepseek" + + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setattr(model_catalog, "_user_tier", free_tier) + monkeypatch.setattr(energy_registry, "active_provider", active_provider) + + catalog = await model_catalog.list_models_for_user(None) + flash = next(item for item in catalog["providers"] if item["provider_id"] == "deepseek") + pro = next(item for item in catalog["providers"] if item["provider_id"] == "deepseek-pro") + + assert "deepseek-v4-pro" not in {item["model_id"] for item in flash["models"]} + assert pro["tier_blocked"] is True + + @pytest.mark.asyncio async def test_openai_wrapper_preserves_reasoning_and_store_contract( monkeypatch: pytest.MonkeyPatch, From 5491892ae8839021ebd0b9e7db9ad098338d0898 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 11:52:11 -0700 Subject: [PATCH 06/48] fix(provider): fingerprint semantic tool repeats --- python/services/inference.py | 29 +++---- .../providers/openai_compatible_provider.py | 4 +- repl_nix_workspace.egg-info/SOURCES.txt | 1 + tests/test_openai_compatible_provider.py | 77 ++++++++++++++++++- 4 files changed, 93 insertions(+), 18 deletions(-) diff --git a/python/services/inference.py b/python/services/inference.py index f89d2c1c2..27c55f308 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,4 +1,4 @@ -# 406:107 0:0 16:15 +# 400:114 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference @@ -19,21 +19,20 @@ # since: 2026-06-02 # unresolved: none # === END MODULE_BUILD === -import os +# === CONTRACTS === +# id: inference_tool_repeat_fingerprint_ignores_transport_ids +# given: consecutive tool calls have the same function name and semantic arguments but different provider-generated ids +# then: _canonical_tool_calls emits the same fingerprint so the second execution is refused +# class: safety +# since: 2026-09-09 +# === END CONTRACTS === import json -import copy -import random -import asyncio import logging -from typing import Optional, Callable, Awaitable, Any +import os +from typing import Callable, Optional import httpx -from .tool_executor import ( - TOOL_SCHEMAS_CHAT, - TOOL_SCHEMAS_RESPONSES, - execute_tool, - set_caller_provider, -) +from .tool_executor import TOOL_SCHEMAS_CHAT, TOOL_SCHEMAS_RESPONSES, execute_tool, set_caller_provider from .prompt_assembly import _prepend_doctrine from .attachments import build_provider_messages as _build_provider_messages # Single source of truth for provider specs — loaded from python/config/providers.json. @@ -227,7 +226,7 @@ def _sanitize_provider_error(provider: str, exc: BaseException) -> str: def _canonical_tool_calls(tool_calls: list[dict]) -> str: - """Produce a stable string fingerprint of a list of tool calls for repeat detection.""" + """Fingerprint semantic tool requests without volatile transport identifiers.""" norm = [] for tc in tool_calls: if "function" in tc: @@ -244,6 +243,8 @@ def _canonical_tool_calls(tool_calls: list[dict]) -> str: args_str = json.dumps(args_obj, sort_keys=True, default=str) except Exception: args_str = str(args) + norm.append({"name": str(name), "arguments": args_str}) + return json.dumps(norm, sort_keys=True, separators=(",", ":")) # Anthropic API version (stable; new features arrive via anthropic-beta header). _ANTHROPIC_VERSION = "2023-06-01" @@ -577,4 +578,4 @@ async def _call_anthropic( ) -# 406:107 0:0 16:15 +# 400:114 0:0 16:15 diff --git a/python/services/providers/openai_compatible_provider.py b/python/services/providers/openai_compatible_provider.py index 4c37b29c5..70661a62b 100644 --- a/python/services/providers/openai_compatible_provider.py +++ b/python/services/providers/openai_compatible_provider.py @@ -204,7 +204,7 @@ async def _call_responses( tool_calls = [item for item in output_items if item.get("type") == "function_call"] if tool_calls: - fingerprint = _canonical_tool_calls(tool_calls) or json.dumps(tool_calls, sort_keys=True, default=str) + fingerprint = _canonical_tool_calls(tool_calls) if fingerprint == previous_fingerprint: return "[noticed repeat tool call — answering directly]", accumulated_usage previous_fingerprint = fingerprint @@ -312,7 +312,7 @@ async def _call_chat_completions( message = (choices[0].get("message") if choices else None) or {} tool_calls = message.get("tool_calls") or [] if tool_calls: - fingerprint = _canonical_tool_calls(tool_calls) or json.dumps(tool_calls, sort_keys=True, default=str) + fingerprint = _canonical_tool_calls(tool_calls) if fingerprint == previous_fingerprint: return "[noticed repeat tool call — answering directly]", accumulated_usage previous_fingerprint = fingerprint diff --git a/repl_nix_workspace.egg-info/SOURCES.txt b/repl_nix_workspace.egg-info/SOURCES.txt index 96ca2178e..f702a006c 100644 --- a/repl_nix_workspace.egg-info/SOURCES.txt +++ b/repl_nix_workspace.egg-info/SOURCES.txt @@ -145,6 +145,7 @@ python/tests/contract_runner.py python/tests/test_coherence_primes.py python/tests/test_contract_runner.py python/tests/test_encoder_compiles.py +python/tests/test_openai_compatible_contracts.py python/tests/test_platonic_agent.py python/tests/test_ptcna_state.py python/tests/test_runtime_readiness.py diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index 24239e301..e499f45d0 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -1,4 +1,4 @@ -# 196:1 0:0 0:0 +# 369:1 0:0 0:0 """Contract tests for registry-driven OpenAI-compatible providers.""" from pathlib import Path @@ -25,6 +25,27 @@ def _clear_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(api_key_env, raising=False) +def test_repeat_fingerprint_excludes_volatile_transport_ids() -> None: + from python.services.inference import _canonical_tool_calls + + first = [{ + "type": "function_call", + "name": "sys.pwd", + "arguments": '{"depth": 1}', + "call_id": "call-first", + "id": "item-first", + }] + repeated = [{ + "type": "function_call", + "name": "sys.pwd", + "arguments": '{"depth":1}', + "call_id": "call-second", + "id": "item-second", + }] + + assert _canonical_tool_calls(first) == _canonical_tool_calls(repeated) + + def test_deepseek_is_configuration_not_a_provider_specific_adapter() -> None: from python.services.energy_registry import BUILTIN_PROVIDERS @@ -183,6 +204,58 @@ async def fake_execute(name: str, arguments: dict) -> str: assert len(responses) == 2 +@pytest.mark.asyncio +async def test_repeated_responses_tool_call_with_new_ids_executes_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import tool_executor + from python.services.providers import openai_compatible_provider as provider + + responses: list[dict] = [] + executed: list[tuple[str, dict]] = [] + + class FakeAsyncOpenAI: + def __init__(self, **kwargs): + async def create(**request): + responses.append(request) + call_number = len(responses) + return _Dump({ + "output": [{ + "type": "function_call", + "name": "sys.pwd", + "arguments": "{}", + "call_id": f"call-{call_number}", + "id": f"item-{call_number}", + }], + "usage": {}, + }) + + self.responses = SimpleNamespace(create=create) + + async def fake_execute(name: str, arguments: dict) -> str: + executed.append((name, arguments)) + return "pwd-ok" + + monkeypatch.setattr(provider, "AsyncOpenAI", FakeAsyncOpenAI) + monkeypatch.setattr( + provider, + "_response_tools", + lambda profile: [{"type": "function", "name": "sys.pwd"}], + ) + monkeypatch.setattr(tool_executor, "execute_tool", fake_execute) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + + content, _ = await provider.call( + [{"role": "user", "content": "repeat a tool"}], + provider_id="deepseek", + use_tools=True, + ) + + assert content == "[noticed repeat tool call — answering directly]" + assert executed == [("sys.pwd", {})] + assert len(responses) == 2 + + @pytest.mark.asyncio async def test_generic_transport_supports_chat_completions( monkeypatch: pytest.MonkeyPatch, @@ -393,4 +466,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 196:1 0:0 0:0 +# 369:1 0:0 0:0 From d0d732994ccfdee76d8a1c312a0e9ff5c451d183 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 12:22:20 -0700 Subject: [PATCH 07/48] fix(providers): preserve routed reasoning state --- python/services/inference.py | 15 ++++++--- .../providers/openai_compatible_provider.py | 14 ++++++-- tests/test_openai_compatible_provider.py | 33 ++++++++++++++----- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/python/services/inference.py b/python/services/inference.py index 27c55f308..0dcd88328 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,4 +1,4 @@ -# 400:114 0:0 16:15 +# 399:120 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference @@ -25,6 +25,12 @@ # then: _canonical_tool_calls emits the same fingerprint so the second execution is refused # class: safety # since: 2026-09-09 +# +# id: inference_compatible_provider_receives_classified_role +# given: routing classifies a request into a role slot handled by an OpenAI-compatible provider +# then: the compatible transport receives that exact role for model override resolution +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === import json import logging @@ -32,7 +38,6 @@ from typing import Callable, Optional import httpx -from .tool_executor import TOOL_SCHEMAS_CHAT, TOOL_SCHEMAS_RESPONSES, execute_tool, set_caller_provider from .prompt_assembly import _prepend_doctrine from .attachments import build_provider_messages as _build_provider_messages # Single source of truth for provider specs — loaded from python/config/providers.json. @@ -359,7 +364,7 @@ async def call_provider( from .providers.openai_compatible_provider import call as compatible_call return await compatible_call( payload_messages, - provider_id=provider_id, + provider_id=provider_id, role=_slot, model_override=spec["model"], api_key=api_key, max_tokens=max_tokens, @@ -385,7 +390,7 @@ async def call_provider( from .providers.openai_compatible_provider import call as compatible_call return await compatible_call( payload_messages, - provider_id=provider_id, + provider_id=provider_id, role=_slot, api_key=api_key, model_override=spec["model"], max_tokens=max_tokens, @@ -578,4 +583,4 @@ async def _call_anthropic( ) -# 400:114 0:0 16:15 +# 399:120 0:0 16:15 diff --git a/python/services/providers/openai_compatible_provider.py b/python/services/providers/openai_compatible_provider.py index 70661a62b..a1c0a763b 100644 --- a/python/services/providers/openai_compatible_provider.py +++ b/python/services/providers/openai_compatible_provider.py @@ -1,4 +1,4 @@ -# 327:35 0:0 2:5 +# 327:43 0:0 2:5 """Generic OpenAI-compatible provider transport. Provider identity, endpoint, credential name, model, API family, reasoning @@ -34,6 +34,12 @@ # then: endpoint, model, credential name, API family, effort scale, and tool profile come from providers.json; missing explicit configuration fails closed and credentials do not enter error text # class: correctness # since: 2026-09-07 +# +# id: openai_compatible_responses_preserves_reasoning_items +# given: a Responses tool round emits reasoning and function-call output items +# then: the continuation includes the complete output sequence before function-call outputs +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === import copy @@ -234,7 +240,9 @@ async def _call_responses( except Exception as exc: return _safe_provider_error(provider_name, exc, api_key), accumulated_usage - input_items.extend(tool_calls) + # Responses continuations without previous_response_id must replay the + # complete output sequence, including encrypted/reasoning state. + input_items.extend(copy.deepcopy(output_items)) for tool_call in tool_calls: try: arguments = json.loads(tool_call.get("arguments", "{}")) @@ -408,4 +416,4 @@ async def call( raise ValueError( f"Provider {provider_id!r} has unsupported api_family={api_family!r}" ) -# 327:35 0:0 2:5 +# 327:43 0:0 2:5 diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index e499f45d0..fc76bd6ba 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -1,4 +1,4 @@ -# 369:1 0:0 0:0 +# 384:1 0:0 0:0 """Contract tests for registry-driven OpenAI-compatible providers.""" from pathlib import Path @@ -166,12 +166,20 @@ async def create(**request): responses.append(request) if len(responses) == 1: return _Dump({ - "output": [{ - "type": "function_call", - "name": "sys.pwd", - "arguments": "{}", - "call_id": "call-1", - }], + "output": [ + { + "type": "reasoning", + "id": "reasoning-1", + "encrypted_content": "opaque-state", + "summary": [], + }, + { + "type": "function_call", + "name": "sys.pwd", + "arguments": "{}", + "call_id": "call-1", + }, + ], "usage": {}, }) return _Dump({ @@ -202,6 +210,11 @@ async def fake_execute(name: str, arguments: dict) -> str: assert content == "tool-ok" assert executed == [("sys.pwd", {})] assert len(responses) == 2 + continuation = responses[1]["input"] + assert continuation[-3]["type"] == "reasoning" + assert continuation[-3]["encrypted_content"] == "opaque-state" + assert continuation[-2]["type"] == "function_call" + assert continuation[-1]["type"] == "function_call_output" @pytest.mark.asyncio @@ -416,12 +429,13 @@ async def fake_compatible_call(messages, **kwargs): async def test_inference_dispatches_adapter_field_without_database( monkeypatch: pytest.MonkeyPatch, ) -> None: - from python.services import inference + from python.services import inference, openai_router from python.services.providers import openai_compatible_provider as provider _clear_provider_keys(monkeypatch) monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") captured: dict = {} async def fake_call(messages, **kwargs): @@ -441,6 +455,7 @@ async def fake_call(messages, **kwargs): assert usage == {"total_tokens": 1} assert captured["provider_id"] == "deepseek" assert captured["model_override"] == "deepseek-v4-flash" + assert captured["role"] == "practice" @pytest.mark.asyncio @@ -466,4 +481,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 369:1 0:0 0:0 +# 384:1 0:0 0:0 From 0c6978d6025db89368909c71d427eb549b6f7472 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 12:40:15 -0700 Subject: [PATCH 08/48] fix(providers): harden stateless fan-out paths --- a0/adapters/openai_compatible_adapter.py | 12 +++++++++--- client/src/components/chat-input.tsx | 7 ++++--- python/routes/instances_api.py | 10 ++++++++-- .../providers/openai_compatible_provider.py | 12 ++++++++++-- tests/test_a0_openai_compatible_adapter.py | 13 +++++++++++-- tests/test_a0_package.py | 13 +++++++++++-- tests/test_openai_compatible_provider.py | 14 ++++++++++++-- 7 files changed, 65 insertions(+), 16 deletions(-) diff --git a/a0/adapters/openai_compatible_adapter.py b/a0/adapters/openai_compatible_adapter.py index 2d2cced91..d79297083 100644 --- a/a0/adapters/openai_compatible_adapter.py +++ b/a0/adapters/openai_compatible_adapter.py @@ -1,4 +1,4 @@ -# 93:29 0:0 2:0 +# 93:35 0:0 2:0 """Synchronous standalone adapter for registry-defined OpenAI-compatible APIs.""" from __future__ import annotations @@ -29,6 +29,12 @@ # then: the registry-derived client executes the configured API family and returns text/usage without credentials; missing keys and unsupported families fail closed # class: correctness # since: 2026-09-07 +# +# id: a0_openai_compatible_error_suppresses_secret_cause +# given: an upstream compatible-provider exception may echo its credential +# then: the public RuntimeError omits the original exception cause +# class: safety +# since: 2026-09-09 # === END CONTRACTS === import os @@ -126,7 +132,7 @@ def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: except Exception as exc: raise RuntimeError( f"{self.provider_id} request failed: {type(exc).__name__}" - ) from exc + ) from None return { "text": text or f"[{self.provider_id}: empty response]", @@ -137,4 +143,4 @@ def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: }, "subagents_used": [], } -# 93:29 0:0 2:0 +# 93:35 0:0 2:0 diff --git a/client/src/components/chat-input.tsx b/client/src/components/chat-input.tsx index 5ed1426ad..9170dffe8 100644 --- a/client/src/components/chat-input.tsx +++ b/client/src/components/chat-input.tsx @@ -1,4 +1,4 @@ -// 364:7 0:3 0:6 +// 365:7 0:3 0:6 // N:M import { useState, useRef, useEffect, useCallback } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; @@ -58,6 +58,7 @@ interface InstanceRow { id: string; canonical_name: string; vendor: string; + provider_id?: string | null; model_id: string; role_slot?: string | null; } @@ -187,7 +188,7 @@ export function ChatInput({ const providerSet = new Set(); const selectedInst = instances.filter((i) => selectedInstances.includes(i.id)); selectedInst.forEach((i) => { - const pid = VENDOR_TO_PROVIDER[i.vendor]; + const pid = i.provider_id ?? VENDOR_TO_PROVIDER[i.vendor]; if (pid) providerSet.add(pid); }); opts.orchestration_mode = "fan_out"; @@ -397,4 +398,4 @@ export function ChatInput({ ); } // N:M -// 364:7 0:3 0:6 +// 365:7 0:3 0:6 diff --git a/python/routes/instances_api.py b/python/routes/instances_api.py index bb9240c1f..f0e7af000 100644 --- a/python/routes/instances_api.py +++ b/python/routes/instances_api.py @@ -1,4 +1,4 @@ -# 338:42 3:17 1:4 +# 344:42 3:17 1:4 # DOC module: instances_api # DOC label: Model Instances # DOC description: CRUD for model instances (D&D party), per-instance memory, task board, and chat/archive sub-routes. @@ -37,6 +37,7 @@ from ..database import get_session from ._admin_gate import require_admin from ..services.agent_instance import AgentInstance +from ..services.model_catalog import resolve_model_id router = APIRouter(prefix="/api/v1", tags=["instances"]) _log = logging.getLogger("a0p.instances_api") @@ -173,6 +174,10 @@ async def list_instances(): result = [] for r in rows: iid = str(r["id"]) + try: + provider_id, _ = await resolve_model_id(str(r["model_id"])) + except ValueError: + provider_id = None mem_n = (await s.execute( _sql("SELECT COUNT(*) FROM instance_memory WHERE instance_id = :id"), {"id": iid} )).scalar() or 0 @@ -183,6 +188,7 @@ async def list_instances(): result.append({ **{k: v for k, v in r.items() if k != "id"}, "id": iid, + "provider_id": provider_id, "created_at": str(r["created_at"]), "memory_count": int(mem_n), "open_task_count": int(task_n), @@ -454,4 +460,4 @@ async def get_archives(iid: str): return [{"id": str(r["id"]), "label": r["label"], "archived_at": str(r["archived_at"]), "merge_status": r["merge_status"]} for r in rows] -# 338:42 3:17 1:4 +# 344:42 3:17 1:4 diff --git a/python/services/providers/openai_compatible_provider.py b/python/services/providers/openai_compatible_provider.py index a1c0a763b..84c8d8862 100644 --- a/python/services/providers/openai_compatible_provider.py +++ b/python/services/providers/openai_compatible_provider.py @@ -1,4 +1,4 @@ -# 327:43 0:0 2:5 +# 329:49 0:0 2:5 """Generic OpenAI-compatible provider transport. Provider identity, endpoint, credential name, model, API family, reasoning @@ -40,6 +40,12 @@ # then: the continuation includes the complete output sequence before function-call outputs # class: correctness # since: 2026-09-09 +# +# id: openai_stateless_reasoning_is_replayable +# given: an OpenAI Responses request enables reasoning while store is false +# then: reasoning.encrypted_content is requested for the stateless continuation +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === import copy @@ -150,6 +156,8 @@ def _responses_kwargs( kwargs["store"] = store if reasoning_effort and reasoning_effort != "none": kwargs["reasoning"] = {"effort": reasoning_effort} + if supports_store and not store: + kwargs["include"] = ["reasoning.encrypted_content"] if tools: kwargs["tools"] = tools return kwargs @@ -416,4 +424,4 @@ async def call( raise ValueError( f"Provider {provider_id!r} has unsupported api_family={api_family!r}" ) -# 327:43 0:0 2:5 +# 329:49 0:0 2:5 diff --git a/tests/test_a0_openai_compatible_adapter.py b/tests/test_a0_openai_compatible_adapter.py index b506174ec..5efb3046f 100644 --- a/tests/test_a0_openai_compatible_adapter.py +++ b/tests/test_a0_openai_compatible_adapter.py @@ -1,4 +1,4 @@ -# 82:1 0:0 0:0 +# 89:1 0:0 0:0 """Standalone a0 selection and OpenAI-compatible adapter contracts.""" from types import SimpleNamespace @@ -81,6 +81,15 @@ def create(**request): assert captured["request"]["model"] == "deepseek-v4-pro" assert captured["request"]["reasoning"] == {"effort": "high"} + def fail_with_secret(**request): + raise RuntimeError("upstream echoed test-secret") + + adapter._client.responses.create = fail_with_secret + with pytest.raises(RuntimeError, match="deepseek-pro request failed") as caught: + adapter.complete([{"role": "user", "content": "fail"}]) + assert caught.value.__cause__ is None + assert "test-secret" not in str(caught.value) + def test_router_prefers_configured_generic_adapter( monkeypatch: pytest.MonkeyPatch, @@ -113,4 +122,4 @@ def test_explicit_provider_missing_key_fails_closed( with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): _select_adapter(request) -# 82:1 0:0 0:0 +# 89:1 0:0 0:0 diff --git a/tests/test_a0_package.py b/tests/test_a0_package.py index 2e103813b..cb305c23c 100644 --- a/tests/test_a0_package.py +++ b/tests/test_a0_package.py @@ -1,4 +1,4 @@ -# 63:10 0:0 0:0 +# 70:10 0:0 0:0 # DOC module: tests.test_a0_package # DOC label: a0 package import + CLI smoke # DOC description: Imports every module under the a0/ package to catch @@ -8,6 +8,7 @@ import importlib import json import pkgutil +from pathlib import Path import subprocess import sys @@ -91,4 +92,12 @@ def test_a0_cli_smoke(tmp_path): out = json.loads(proc.stdout.decode("utf-8")) assert out["task_id"] == "smoke1" assert "result" in out -# 63:10 0:0 0:0 + + +def test_instance_fanout_propagates_resolved_provider_id(): + root = Path(__file__).resolve().parents[1] + api = (root / "python/routes/instances_api.py").read_text(encoding="utf-8") + ui = (root / "client/src/components/chat-input.tsx").read_text(encoding="utf-8") + assert '"provider_id": provider_id' in api + assert "i.provider_id ?? VENDOR_TO_PROVIDER[i.vendor]" in ui +# 70:10 0:0 0:0 diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index fc76bd6ba..65cd72c6c 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -1,4 +1,4 @@ -# 384:1 0:0 0:0 +# 391:1 0:0 0:0 """Contract tests for registry-driven OpenAI-compatible providers.""" from pathlib import Path @@ -46,6 +46,16 @@ def test_repeat_fingerprint_excludes_volatile_transport_ids() -> None: assert _canonical_tool_calls(first) == _canonical_tool_calls(repeated) +def test_stateless_openai_reasoning_requests_encrypted_state() -> None: + from python.services.providers.openai_compatible_provider import _responses_kwargs + + kwargs = _responses_kwargs( + model="gpt-test", input_items=[], max_output_tokens=8, temperature=1.0, + reasoning_effort="high", store=False, supports_store=True, tools=None, + ) + assert kwargs["include"] == ["reasoning.encrypted_content"] + + def test_deepseek_is_configuration_not_a_provider_specific_adapter() -> None: from python.services.energy_registry import BUILTIN_PROVIDERS @@ -481,4 +491,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 384:1 0:0 0:0 +# 391:1 0:0 0:0 From 2ab72d0e266b271ece21b3181d6fa72fc8109daf Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 13:09:18 -0700 Subject: [PATCH 09/48] fix(providers): isolate fan-out routing context --- python/services/energy_registry.py | 1 + python/services/inference.py | 17 +++- .../providers/openai_compatible_provider.py | 77 ++++++++++--------- tests/test_openai_compatible_provider.py | 63 +++++++++++++-- 4 files changed, 115 insertions(+), 43 deletions(-) diff --git a/python/services/energy_registry.py b/python/services/energy_registry.py index aee5d418a..8ebdb85ea 100644 --- a/python/services/energy_registry.py +++ b/python/services/energy_registry.py @@ -305,6 +305,7 @@ def _on_progress(cum_chars: int, cum_tokens_est: int) -> None: system_prompt=system_context, use_tools=False, progress_callback=_on_progress, + pin_requested_provider=True, ) out = content or "" elapsed_ms = int((_time.perf_counter() - started_at) * 1000) diff --git a/python/services/inference.py b/python/services/inference.py index 0dcd88328..0d6491700 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -31,6 +31,12 @@ # then: the compatible transport receives that exact role for model override resolution # class: correctness # since: 2026-09-09 +# +# id: inference_fanout_preserves_requested_provider +# given: multi-model orchestration explicitly requests one provider for a lane +# then: call_provider uses that provider and its instance memory without replacing it from the shared prompt's role slot +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === import json import logging @@ -295,6 +301,7 @@ async def call_provider( reasoning_effort: Optional[str] = None, progress_callback: Optional[Callable[[int, int], None]] = None, skip_manifest: bool = False, + pin_requested_provider: bool = False, ) -> tuple[str, dict]: """ Forward messages to the named provider with the system prompt prepended. @@ -303,6 +310,8 @@ async def call_provider( skip_approval=True bypasses the approval gate (used for replay after explicit APPROVE). skip_manifest=True omits the skill manifest from the doctrine prefix (saves ~500 tokens; use for internal/automated callers that never invoke skill_load). + pin_requested_provider=True is reserved for multi-model orchestration lanes; + it preserves the lane's provider and loads that provider's instance memory. reasoning_effort is mapped per-provider, gated by capability flags in providers.json (single source of truth — no model slugs in code): - OpenAI: passed via openai_router call_cfg (ignored on the openai branch) @@ -319,9 +328,13 @@ async def call_provider( (m.get("content", "") for m in reversed(messages) if m.get("role") == "user"), "" ) _slot = _resolve_role(_task_text) - _imem, _slot_provider = await _slot_routing_info(_slot) - if not _imem: + if pin_requested_provider: _imem = await _instance_memory_block(provider_id) + _slot_provider = None + else: + _imem, _slot_provider = await _slot_routing_info(_slot) + if not _imem: + _imem = await _instance_memory_block(provider_id) if _imem: system_prompt = (system_prompt or "") + "\n\n## Instance Memory\n" + _imem if _slot_provider: diff --git a/python/services/providers/openai_compatible_provider.py b/python/services/providers/openai_compatible_provider.py index 84c8d8862..7013378b2 100644 --- a/python/services/providers/openai_compatible_provider.py +++ b/python/services/providers/openai_compatible_provider.py @@ -46,6 +46,12 @@ # then: reasoning.encrypted_content is requested for the stateless continuation # class: correctness # since: 2026-09-09 +# +# id: openai_compatible_caller_provider_is_scoped +# given: a compatible-provider call runs inside an async task with an existing caller-provider context +# then: the provider identity is active for the complete transport loop and the prior context is restored on every exit +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === import copy @@ -183,10 +189,8 @@ async def _call_responses( _canonical_tool_calls, _get_max_tool_rounds, ) - from ..tool_distill import set_caller_provider from ..tool_executor import execute_tool - set_caller_provider(provider_name) input_items = _format_responses_messages(input_messages) client_kwargs: dict[str, str] = {"api_key": api_key} if base_url: @@ -294,10 +298,8 @@ async def _call_chat_completions( _canonical_tool_calls, _get_max_tool_rounds, ) - from ..tool_distill import set_caller_provider from ..tool_executor import execute_tool, get_active_chat_schemas - set_caller_provider(provider_name) client_kwargs: dict[str, str] = {"api_key": api_key} if base_url: client_kwargs["base_url"] = base_url.rstrip("/") @@ -392,36 +394,41 @@ async def call( base_url = str(spec.get("base_url") or "").strip() or None effort = _normalize_reasoning_effort(spec, reasoning_effort) api_family = spec.get("api_family", "responses") - - if api_family == "responses": - tools = _response_tools(spec.get("tool_profile", "all-responses")) if use_tools else None - return await _call_responses( - api_key=key, - model=model, - input_messages=messages, - max_output_tokens=max_tokens, - temperature=temperature, - reasoning_effort=effort, - store=store, - use_tools=use_tools, - base_url=base_url, - provider_name=provider_id, - tools_override=tools, - supports_store=bool(spec.get("supports_store", spec.get("vendor") == "openai")), + from ..tool_distill import reset_caller_provider, set_caller_provider + + caller_provider_token = set_caller_provider(provider_id) + try: + if api_family == "responses": + tools = _response_tools(spec.get("tool_profile", "all-responses")) if use_tools else None + return await _call_responses( + api_key=key, + model=model, + input_messages=messages, + max_output_tokens=max_tokens, + temperature=temperature, + reasoning_effort=effort, + store=store, + use_tools=use_tools, + base_url=base_url, + provider_name=provider_id, + tools_override=tools, + supports_store=bool(spec.get("supports_store", spec.get("vendor") == "openai")), + ) + if api_family == "chat_completions": + return await _call_chat_completions( + api_key=key, + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + reasoning_effort=effort, + use_tools=use_tools, + base_url=base_url, + provider_name=provider_id, + ) + raise ValueError( + f"Provider {provider_id!r} has unsupported api_family={api_family!r}" ) - if api_family == "chat_completions": - return await _call_chat_completions( - api_key=key, - model=model, - messages=messages, - max_tokens=max_tokens, - temperature=temperature, - reasoning_effort=effort, - use_tools=use_tools, - base_url=base_url, - provider_name=provider_id, - ) - raise ValueError( - f"Provider {provider_id!r} has unsupported api_family={api_family!r}" - ) + finally: + reset_caller_provider(caller_provider_token) # 329:49 0:0 2:5 diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index 65cd72c6c..fcb3047a6 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -79,6 +79,11 @@ def test_deepseek_is_configuration_not_a_provider_specific_adapter() -> None: async def test_responses_transport_uses_registry_base_url_model_and_effort( monkeypatch: pytest.MonkeyPatch, ) -> None: + from python.services.tool_distill import ( + get_caller_provider, + reset_caller_provider, + set_caller_provider, + ) from python.services.providers import openai_compatible_provider as provider captured: dict = {"requests": []} @@ -102,12 +107,17 @@ async def create(**request): monkeypatch.setattr(provider, "AsyncOpenAI", FakeAsyncOpenAI) monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") - content, usage = await provider.call( - [{"role": "user", "content": "hi"}], - provider_id="deepseek-pro", - use_tools=False, - reasoning_effort="medium", - ) + outer_token = set_caller_provider("outer-provider") + try: + content, usage = await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek-pro", + use_tools=False, + reasoning_effort="medium", + ) + assert get_caller_provider() == "outer-provider" + finally: + reset_caller_provider(outer_token) assert content == "ok" assert usage == {"input_tokens": 2, "output_tokens": 1} @@ -468,6 +478,47 @@ async def fake_call(messages, **kwargs): assert captured["role"] == "practice" +@pytest.mark.asyncio +async def test_fanout_bridge_pins_each_requested_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import energy_registry, inference, openai_router + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") + + async def conflicting_slot(_slot: str) -> tuple[str, str]: + return "wrong-slot-memory", "openai" + + async def selected_memory(provider_id: str) -> str: + assert provider_id == "deepseek-pro" + return "selected-provider-memory" + + captured: dict = {} + + async def fake_call(messages, **kwargs): + captured["messages"] = messages + captured.update(kwargs) + return "fanout-ok", {} + + monkeypatch.setattr(inference, "_slot_routing_info", conflicting_slot) + monkeypatch.setattr(inference, "_instance_memory_block", selected_memory) + monkeypatch.setattr(provider, "call", fake_call) + + content = await energy_registry._aimmh_call_fn( + "deepseek-pro", + [{"role": "user", "content": "practice this"}], + ) + + assert content == "fanout-ok" + assert captured["provider_id"] == "deepseek-pro" + system_text = captured["messages"][0]["content"] + assert "selected-provider-memory" in system_text + assert "wrong-slot-memory" not in system_text + + @pytest.mark.asyncio async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( monkeypatch: pytest.MonkeyPatch, From 2a981aef656cee336d8839addcdddb49820be4a5 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 13:52:57 -0700 Subject: [PATCH 10/48] fix(routing): preserve explicit model pins --- python/services/call_fn.py | 9 +++++ tests/test_openai_compatible_provider.py | 43 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/python/services/call_fn.py b/python/services/call_fn.py index f0f2c8580..ab0019a1c 100644 --- a/python/services/call_fn.py +++ b/python/services/call_fn.py @@ -54,6 +54,14 @@ # unresolved: none # === END MODULE_BUILD === +# === CONTRACTS === +# id: call_fn_resolved_model_pins_provider +# given: call_model resolves an explicit model id after its enabled and tier gates +# then: the resolved provider is pinned through inference and cannot be replaced by a prompt role slot +# class: correctness +# since: 2026-09-09 +# === END CONTRACTS === + from typing import Awaitable, Callable, Optional from .inference import call_provider @@ -132,6 +140,7 @@ async def call_model( user_id=user_id, skip_approval=skip_approval, reasoning_effort=reasoning_effort, + pin_requested_provider=True, ) return content, usage diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index fcb3047a6..f9973518d 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -519,6 +519,49 @@ async def fake_call(messages, **kwargs): assert "wrong-slot-memory" not in system_text +@pytest.mark.asyncio +async def test_call_model_pins_the_explicit_model_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import call_fn, inference, openai_router + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") + + async def conflicting_slot(_slot: str) -> tuple[str, str]: + return "wrong-slot-memory", "deepseek-pro" + + async def selected_memory(provider_id: str) -> str: + assert provider_id == "deepseek" + return "flash-memory" + + captured: dict = {} + + async def fake_call(messages, **kwargs): + captured["messages"] = messages + captured.update(kwargs) + return "single-ok", {} + + monkeypatch.setattr(inference, "_slot_routing_info", conflicting_slot) + monkeypatch.setattr(inference, "_instance_memory_block", selected_memory) + monkeypatch.setattr(provider, "call", fake_call) + + content, _ = await call_fn.call_model( + "deepseek-v4-flash", + [{"role": "user", "content": "practice this"}], + enforce_tier=False, + enforce_enabled=False, + ) + + assert content == "single-ok" + assert captured["provider_id"] == "deepseek" + system_text = captured["messages"][0]["content"] + assert "flash-memory" in system_text + assert "wrong-slot-memory" not in system_text + + @pytest.mark.asyncio async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( monkeypatch: pytest.MonkeyPatch, From d9e8139c66b5f563ac520c5bba11d3e83f549df9 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 14:35:31 -0700 Subject: [PATCH 11/48] fix(providers): close final review boundaries --- .github/workflows/deploy.yml | 2 +- CLAUDE.md | 4 +- a0/__init__.py | 33 ++- a0/adapters/__init__.py | 29 +- ...apter.py => open_comp_adap_v0.0.0alpha.py} | 17 +- ...r_registry.py => prov_regi_v0.0.0alpha.py} | 2 +- a0/router.py | 4 +- a0_msdmd.ts | 224 ++++++++++++++- cloudbuild.yaml | 2 +- docs/ARCHITECTURE.md | 6 +- pyproject.toml | 4 + python/services/inference.py | 39 ++- python/services/providers/__init__.py | 29 +- ...vider.py => open_comp_prov_v0.0.0alpha.py} | 16 +- python/services/providers/openai_provider.py | 10 +- python/tests/contract_runner.py | 21 +- ....py => test_open_comp_cont_v0.0.0alpha.py} | 53 +++- repl_nix_workspace.egg-info/SOURCES.txt | 9 +- suggest.md | 2 +- ...> test_aone_open_comp_adap_v0.0.0alpha.py} | 32 ++- ....py => test_open_comp_prov_v0.0.0alpha.py} | 219 ++------------- tests/test_open_comp_rout_v0.0.0alpha.py | 260 ++++++++++++++++++ 22 files changed, 733 insertions(+), 284 deletions(-) rename a0/adapters/{openai_compatible_adapter.py => open_comp_adap_v0.0.0alpha.py} (90%) rename a0/{provider_registry.py => prov_regi_v0.0.0alpha.py} (97%) rename python/services/providers/{openai_compatible_provider.py => open_comp_prov_v0.0.0alpha.py} (97%) rename python/tests/{test_openai_compatible_contracts.py => test_open_comp_cont_v0.0.0alpha.py} (58%) rename tests/{test_a0_openai_compatible_adapter.py => test_aone_open_comp_adap_v0.0.0alpha.py} (76%) rename tests/{test_openai_compatible_provider.py => test_open_comp_prov_v0.0.0alpha.py} (66%) create mode 100644 tests/test_open_comp_rout_v0.0.0alpha.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c8ee6cdd..45460ac4c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,7 +38,7 @@ jobs: run: uv sync --frozen - name: Run provider contracts - run: uv run pytest -q tests/test_openai_compatible_provider.py tests/test_a0_openai_compatible_adapter.py tests/test_a0_package.py python/tests/test_openai_compatible_contracts.py + run: uv run pytest -q tests/test_open_comp_prov_v0.0.0alpha.py tests/test_open_comp_rout_v0.0.0alpha.py tests/test_aone_open_comp_adap_v0.0.0alpha.py tests/test_a0_package.py python/tests/test_open_comp_cont_v0.0.0alpha.py check-console-tabs: name: Console tab regression guard diff --git a/CLAUDE.md b/CLAUDE.md index ce5c62ba5..2bd6dee92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,8 +134,8 @@ Naming convention: `{name}.py` = self-contained module; `{name}_api.py` = thin d ### Key Python Services & Engines - `python/services/inference.py` — Orchestrates LLM calls across registered energy providers (Grok / Gemini / Claude / OpenAI-compatible); resolves role, normalizes reasoning effort, injects tier-specific `prompt_context`. -- `python/services/providers/openai_compatible_provider.py` — Generic Responses/Chat Completions transport; endpoints, credential env names, models, and provider quirks stay in `python/config/providers.json`. -- `a0/provider_registry.py` + `a0/adapters/openai_compatible_adapter.py` — Standalone/Termux provider selection from that same registry; `A0_PROVIDER` is explicit and fail-closed. +- `python/services/providers/open_comp_prov_v0.0.0alpha.py` — Generic Responses/Chat Completions transport; endpoints, credential env names, models, and provider quirks stay in `python/config/providers.json`. +- `a0/prov_regi_v0.0.0alpha.py` + `a0/adapters/open_comp_adap_v0.0.0alpha.py` — Standalone/Termux provider selection from that same registry; `A0_PROVIDER` is explicit and fail-closed. - `python/services/heartbeat.py` — Periodic tick: audit snapshots, memory checkpoints, PCNA propagation, sub-agent cleanup. - `python/services/tool_executor.py` — Tool invocation with approval gates. - `python/engine/ptcna_state.py` — durable Platonic-Agent adapter over the exactly pinned producer-owned PTCNA pipeline and UCNS receipt. diff --git a/a0/__init__.py b/a0/__init__.py index abc9e0eec..9a3128816 100644 --- a/a0/__init__.py +++ b/a0/__init__.py @@ -1,3 +1,30 @@ -# 0:1 0:0 0:0 -# a0 package -# 0:1 0:0 0:0 +# 21:1 0:0 0:0 +"""a0 package and stable exports for versioned PCEA service modules.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + + +def _load_versioned_module(public_name: str, filename: str): + qualified_name = f"{__name__}.{public_name}" + existing = sys.modules.get(qualified_name) + if existing is not None: + return existing + spec = spec_from_file_location(qualified_name, Path(__file__).with_name(filename)) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {qualified_name} from {filename}") + module = module_from_spec(spec) + sys.modules[qualified_name] = module + spec.loader.exec_module(module) + return module + + +provider_registry = _load_versioned_module( + "provider_registry", "prov_regi_v0.0.0alpha.py" +) +load_provider_registry = provider_registry.load_provider_registry +resolve_openai_compatible_provider = provider_registry.resolve_openai_compatible_provider + +__all__ = ["load_provider_registry", "resolve_openai_compatible_provider"] +# 21:1 0:0 0:0 diff --git a/a0/adapters/__init__.py b/a0/adapters/__init__.py index 2c54fc225..073d33759 100644 --- a/a0/adapters/__init__.py +++ b/a0/adapters/__init__.py @@ -1,12 +1,35 @@ -# 9:0 0:0 0:3 +# 27:0 0:0 0:3 +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + from .claude_agent_adapter import ClaudeAgentAdapter -from .openai_compatible_adapter import OpenAICompatibleAdapter from .subagents import ALL_SUBAGENTS, MODE_SUBAGENTS + +def _load_versioned_module(public_name: str, filename: str): + qualified_name = f"{__name__}.{public_name}" + existing = sys.modules.get(qualified_name) + if existing is not None: + return existing + spec = spec_from_file_location(qualified_name, Path(__file__).with_name(filename)) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {qualified_name} from {filename}") + module = module_from_spec(spec) + sys.modules[qualified_name] = module + spec.loader.exec_module(module) + return module + + +openai_compatible_adapter = _load_versioned_module( + "openai_compatible_adapter", "open_comp_adap_v0.0.0alpha.py" +) +OpenAICompatibleAdapter = openai_compatible_adapter.OpenAICompatibleAdapter + __all__ = [ "ClaudeAgentAdapter", "OpenAICompatibleAdapter", "ALL_SUBAGENTS", "MODE_SUBAGENTS", ] -# 9:0 0:0 0:3 +# 27:0 0:0 0:3 diff --git a/a0/adapters/openai_compatible_adapter.py b/a0/adapters/open_comp_adap_v0.0.0alpha.py similarity index 90% rename from a0/adapters/openai_compatible_adapter.py rename to a0/adapters/open_comp_adap_v0.0.0alpha.py index d79297083..969b97b64 100644 --- a/a0/adapters/openai_compatible_adapter.py +++ b/a0/adapters/open_comp_adap_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 93:35 0:0 2:0 +# 98:41 0:0 2:0 """Synchronous standalone adapter for registry-defined OpenAI-compatible APIs.""" from __future__ import annotations @@ -15,7 +15,7 @@ # network_boundary: external # user_data_boundary: write # admin_only: false -# tests: tests/test_a0_openai_compatible_adapter.py +# tests: tests/test_aone_open_comp_adap_v0.0.0alpha.py # rollout: default_enabled # rollback: Remove this module and restore router selection to Claude/local only. # requires: a0_provider_registry @@ -35,6 +35,12 @@ # then: the public RuntimeError omits the original exception cause # class: safety # since: 2026-09-09 +# +# id: a0_openai_compatible_store_defaults_off +# given: a standalone Responses provider supports upstream response storage +# then: requests send store=false unless the caller explicitly opts in +# class: safety +# since: 2026-09-09 # === END CONTRACTS === import os @@ -105,6 +111,11 @@ def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: "input": messages, "max_output_tokens": max_tokens, } + supports_store = bool( + self.spec.get("supports_store", self.spec.get("vendor") == "openai") + ) + if supports_store: + request["store"] = bool(kwargs.get("store", False)) if effort and effort != "none": request["reasoning"] = {"effort": effort} response = self._client.responses.create(**request) @@ -143,4 +154,4 @@ def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: }, "subagents_used": [], } -# 93:35 0:0 2:0 +# 98:41 0:0 2:0 diff --git a/a0/provider_registry.py b/a0/prov_regi_v0.0.0alpha.py similarity index 97% rename from a0/provider_registry.py rename to a0/prov_regi_v0.0.0alpha.py index 968b52c76..f23c6faf6 100644 --- a/a0/provider_registry.py +++ b/a0/prov_regi_v0.0.0alpha.py @@ -15,7 +15,7 @@ # network_boundary: none # user_data_boundary: none # admin_only: false -# tests: tests/test_a0_openai_compatible_adapter.py +# tests: tests/test_aone_open_comp_adap_v0.0.0alpha.py # rollout: default_enabled # rollback: Remove this module and restore router selection to Claude/local only. # requires: none diff --git a/a0/router.py b/a0/router.py index dcef02e35..6bb66a8b1 100644 --- a/a0/router.py +++ b/a0/router.py @@ -7,7 +7,7 @@ from .logging import log_event from .state import load_state, save_state from .model_adapter import LocalEchoAdapter -from .provider_registry import resolve_openai_compatible_provider +from . import resolve_openai_compatible_provider from .tools.edcm_tool import run_edcm from .tools.pdf_tool import run_pdf_extract @@ -25,7 +25,7 @@ def _select_adapter(req: A0Request): """ provider = resolve_openai_compatible_provider() if provider: - from .adapters.openai_compatible_adapter import OpenAICompatibleAdapter + from .adapters import OpenAICompatibleAdapter provider_id, spec = provider return OpenAICompatibleAdapter(provider_id, spec) diff --git a/a0_msdmd.ts b/a0_msdmd.ts index 679d97d42..1a2d5edc8 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,4 +1,4 @@ -// 7796:0 0:1 0:1 +// 7992:0 0:1 0:1 import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; export default defineMsdmdCollection({ @@ -11,9 +11,31 @@ export default defineMsdmdCollection({ "since": "2026-09-07", "then": "the registry-derived client executes the configured API family and returns text/usage without credentials; missing keys and unsupported families fail closed" }, - "file": "a0/adapters/openai_compatible_adapter.py", + "file": "a0/adapters/open_comp_adap_v0.0.0alpha.py", "id": "a0_openai_compatible_completion" }, + { + "block": "CONTRACTS", + "fields": { + "class": "safety", + "given": "an upstream compatible-provider exception may echo its credential", + "since": "2026-09-09", + "then": "the public RuntimeError omits the original exception cause" + }, + "file": "a0/adapters/open_comp_adap_v0.0.0alpha.py", + "id": "a0_openai_compatible_error_suppresses_secret_cause" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "safety", + "given": "a standalone Responses provider supports upstream response storage", + "since": "2026-09-09", + "then": "requests send store=false unless the caller explicitly opts in" + }, + "file": "a0/adapters/open_comp_adap_v0.0.0alpha.py", + "id": "a0_openai_compatible_store_defaults_off" + }, { "block": "MODULE_BUILD", "fields": { @@ -31,11 +53,11 @@ export default defineMsdmdCollection({ "since": "2026-09-07", "storage_boundary": "none", "summary": "Executes standalone a0 requests against any registry-defined OpenAI-compatible Responses or Chat Completions endpoint.", - "tests": "tests/test_a0_openai_compatible_adapter.py", + "tests": "tests/test_aone_open_comp_adap_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "write" }, - "file": "a0/adapters/openai_compatible_adapter.py", + "file": "a0/adapters/open_comp_adap_v0.0.0alpha.py", "id": "a0_adapter_openai_compatible" }, { @@ -46,7 +68,7 @@ export default defineMsdmdCollection({ "since": "2026-09-07", "then": "an explicit compatible provider resolves or fails closed while an unset choice may auto-select only a configured a0_default provider" }, - "file": "a0/provider_registry.py", + "file": "a0/prov_regi_v0.0.0alpha.py", "id": "a0_provider_selection" }, { @@ -66,11 +88,11 @@ export default defineMsdmdCollection({ "since": "2026-09-07", "storage_boundary": "read", "summary": "Resolves explicit or auto-selected OpenAI-compatible standalone a0 providers from python/config/providers.json.", - "tests": "tests/test_a0_openai_compatible_adapter.py", + "tests": "tests/test_aone_open_comp_adap_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "none" }, - "file": "a0/provider_registry.py", + "file": "a0/prov_regi_v0.0.0alpha.py", "id": "a0_provider_registry" }, { @@ -1037,6 +1059,17 @@ export default defineMsdmdCollection({ "file": "python/services/bg_tasks.py", "id": "a0_service_bg_tasks" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "call_model resolves an explicit model id after its enabled and tier gates", + "since": "2026-09-09", + "then": "the resolved provider is pinned through inference and cannot be replaced by a prompt role slot" + }, + "file": "python/services/call_fn.py", + "id": "call_fn_resolved_model_pins_provider" + }, { "block": "MODULE_BUILD", "fields": { @@ -1377,6 +1410,39 @@ export default defineMsdmdCollection({ "file": "python/services/heartbeat.py", "id": "a0_service_heartbeat" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "routing classifies a request into a role slot handled by an OpenAI-compatible provider", + "since": "2026-09-09", + "then": "the compatible transport receives that exact role for model override resolution" + }, + "file": "python/services/inference.py", + "id": "inference_compatible_provider_receives_classified_role" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "multi-model orchestration explicitly requests one provider for a lane", + "since": "2026-09-09", + "then": "call_provider uses that provider and its instance memory without replacing it from the shared prompt's role slot" + }, + "file": "python/services/inference.py", + "id": "inference_fanout_preserves_requested_provider" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "safety", + "given": "consecutive tool calls have the same function name and semantic arguments but different provider-generated ids", + "since": "2026-09-09", + "then": "_canonical_tool_calls emits the same fingerprint so the second execution is refused" + }, + "file": "python/services/inference.py", + "id": "inference_tool_repeat_fingerprint_ignores_transport_ids" + }, { "block": "MODULE_BUILD", "fields": { @@ -1617,6 +1683,17 @@ export default defineMsdmdCollection({ "file": "python/services/providers/gemini_provider.py", "id": "a0_service_providers_gemini" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "a compatible-provider call runs inside an async task with an existing caller-provider context", + "since": "2026-09-09", + "then": "the provider identity is active for the complete transport loop and the prior context is restored on every exit" + }, + "file": "python/services/providers/open_comp_prov_v0.0.0alpha.py", + "id": "openai_compatible_caller_provider_is_scoped" + }, { "block": "CONTRACTS", "fields": { @@ -1625,9 +1702,31 @@ export default defineMsdmdCollection({ "since": "2026-09-07", "then": "endpoint, model, credential name, API family, effort scale, and tool profile come from providers.json; missing explicit configuration fails closed and credentials do not enter error text" }, - "file": "python/services/providers/openai_compatible_provider.py", + "file": "python/services/providers/open_comp_prov_v0.0.0alpha.py", "id": "openai_compatible_registry_driven" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "a Responses tool round emits reasoning and function-call output items", + "since": "2026-09-09", + "then": "the continuation includes the complete output sequence before function-call outputs" + }, + "file": "python/services/providers/open_comp_prov_v0.0.0alpha.py", + "id": "openai_compatible_responses_preserves_reasoning_items" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "an OpenAI Responses request enables reasoning while store is false", + "since": "2026-09-09", + "then": "reasoning.encrypted_content is requested for the stateless continuation" + }, + "file": "python/services/providers/open_comp_prov_v0.0.0alpha.py", + "id": "openai_stateless_reasoning_is_replayable" + }, { "block": "MODULE_BUILD", "fields": { @@ -1645,11 +1744,11 @@ export default defineMsdmdCollection({ "since": "2026-09-07", "storage_boundary": "none", "summary": "Registry-driven OpenAI-compatible transport supporting Responses and Chat Completions with the shared repeat-safe tool loop.", - "tests": "tests/test_openai_compatible_provider.py", + "tests": "tests/test_open_comp_prov_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "write" }, - "file": "python/services/providers/openai_compatible_provider.py", + "file": "python/services/providers/open_comp_prov_v0.0.0alpha.py", "id": "a0_service_providers_openai_compatible" }, { @@ -1657,7 +1756,7 @@ export default defineMsdmdCollection({ "fields": { "admin_only": "false", "auth_boundary": "none", - "internal_surface": "_call_responses", + "internal_surface": "none", "module_kind": "adapter", "module_name": "openai_provider", "network_boundary": "external", @@ -1669,7 +1768,7 @@ export default defineMsdmdCollection({ "since": "2026-06-02", "storage_boundary": "none", "summary": "Stable OpenAI-specific call surface delegating transport behavior to the generic OpenAI-compatible adapter.", - "tests": "tests/test_openai_compatible_provider.py", + "tests": "tests/test_open_comp_prov_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "write" }, @@ -3210,9 +3309,22 @@ export default defineMsdmdCollection({ "requires": "python3", "timeout": "20" }, - "file": "python/tests/test_openai_compatible_contracts.py", + "file": "python/tests/test_open_comp_cont_v0.0.0alpha.py", "id": "check_openai_compatible_registry_wiring" }, + { + "block": "CHECKS", + "fields": { + "call": "self::check_openai_compatible_repair_regressions", + "cleanup": "none", + "mutates": "none", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped", + "requires": "python3, pytest", + "timeout": "60" + }, + "file": "python/tests/test_open_comp_cont_v0.0.0alpha.py", + "id": "check_openai_compatible_repair_regressions" + }, { "block": "CHECKS", "fields": { @@ -5104,6 +5216,90 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_registry_wiring", "to": "python3" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "calls", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "self::check_openai_compatible_repair_regressions" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "a0_openai_compatible_error_suppresses_secret_cause" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "a0_openai_compatible_store_defaults_off" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "call_fn_resolved_model_pins_provider" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "inference_compatible_provider_receives_classified_role" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "inference_fanout_preserves_requested_provider" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "inference_tool_repeat_fingerprint_ignores_transport_ids" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "openai_compatible_caller_provider_is_scoped" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "openai_compatible_responses_preserves_reasoning_items" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "openai_stateless_reasoning_is_replayable" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "requires", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "pytest" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "requires", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "python3" + }, { "from": "check_platonic_agent_existing_separations_preserved", "kind": "calls", @@ -7796,4 +7992,4 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 7796:0 0:1 0:1 +// 7992:0 0:1 0:1 diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 4be9e4963..386bc8bdb 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -34,7 +34,7 @@ jobs: run: pip install -e . pytest pytest-asyncio - name: Run provider contracts - run: pytest -q tests/test_openai_compatible_provider.py tests/test_a0_openai_compatible_adapter.py tests/test_a0_package.py python/tests/test_openai_compatible_contracts.py + run: pytest -q tests/test_open_comp_prov_v0.0.0alpha.py tests/test_open_comp_rout_v0.0.0alpha.py tests/test_aone_open_comp_adap_v0.0.0alpha.py tests/test_a0_package.py python/tests/test_open_comp_cont_v0.0.0alpha.py check-console-tabs: name: Console tab regression guard diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6b7762d21..62b3f89c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,14 +16,14 @@ - `python/services/inference.py` — Dispatcher + orchestration; delegates outbound API calls to `providers/.py` - `python/services/providers/` — Native adapters plus generic transports: - `_resolver.py` — registry-defined env override > spec model lookup; raises on unresolvable - - `openai_compatible_provider.py` — provider-neutral Responses/Chat Completions transport + tool loop + - `open_comp_prov_v0.0.0alpha.py` — provider-neutral Responses/Chat Completions transport + tool loop - `openai_provider.py` — stable OpenAI wrapper over the generic transport - `xai_provider.py` — xAI Grok via native xai-sdk (search + function-tool loop + streaming) - `gemini_provider.py` — google-genai SDK (thin wrapper over `gemini_native.py`) - `claude_provider.py` — Anthropic SDK + prompt caching - `python/services/provider_seeds_bootstrap.py` — Lifespan-time idempotent seeding of provider WS modules -- `a0/provider_registry.py` — standalone/Termux selection from the canonical provider JSON; explicit `A0_PROVIDER` fails closed -- `a0/adapters/openai_compatible_adapter.py` — synchronous standalone transport for registry-defined compatible providers +- `a0/prov_regi_v0.0.0alpha.py` — standalone/Termux selection from the canonical provider JSON; explicit `A0_PROVIDER` fails closed +- `a0/adapters/open_comp_adap_v0.0.0alpha.py` — synchronous standalone transport for registry-defined compatible providers - `python/services/heartbeat.py` — Background heartbeat service (30s tick) - `python/services/bandit.py` — Multi-Armed Bandit (UCB1) service - `python/services/edcm.py` — EDCM behavioral directives scoring diff --git a/pyproject.toml b/pyproject.toml index c5e61793f..d774324ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["python*"] +[tool.setuptools.package-data] +"python.services.providers" = ["open_comp_prov_v0.0.0alpha.py"] + [project] name = "repl-nix-workspace" @@ -43,6 +46,7 @@ dependencies = [ ] [tool.pytest.ini_options] +addopts = "--import-mode=importlib" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" diff --git a/python/services/inference.py b/python/services/inference.py index 0d6491700..1663ac6b1 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,4 +1,4 @@ -# 399:120 0:0 16:15 +# 398:129 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference @@ -310,8 +310,9 @@ async def call_provider( skip_approval=True bypasses the approval gate (used for replay after explicit APPROVE). skip_manifest=True omits the skill manifest from the doctrine prefix (saves ~500 tokens; use for internal/automated callers that never invoke skill_load). - pin_requested_provider=True is reserved for multi-model orchestration lanes; - it preserves the lane's provider and loads that provider's instance memory. + pin_requested_provider=True is reserved for explicit-model and multi-model + orchestration lanes; it preserves the resolved provider, model, and that + provider's instance memory. reasoning_effort is mapped per-provider, gated by capability flags in providers.json (single source of truth — no model slugs in code): - OpenAI: passed via openai_router call_cfg (ignored on the openai branch) @@ -374,15 +375,12 @@ async def call_provider( if system_prompt: payload_messages.append({"role": "system", "content": system_prompt}) payload_messages.extend(messages) - from .providers.openai_compatible_provider import call as compatible_call - return await compatible_call( - payload_messages, - provider_id=provider_id, role=_slot, - model_override=spec["model"], - api_key=api_key, - max_tokens=max_tokens, - use_tools=use_tools, - reasoning_effort=effective_effort, + from .providers import openai_compatible_provider + return await openai_compatible_provider.call( + payload_messages, provider_id=provider_id, role=_slot, + model_override=spec["model"], api_key=api_key, max_tokens=max_tokens, + use_tools=use_tools, reasoning_effort=effective_effort, + pin_model_override=pin_requested_provider, ) api_key = os.environ.get(spec["api_key_env"], "") @@ -400,15 +398,12 @@ async def call_provider( vendor = spec.get("vendor", "") if spec.get("adapter") == "openai-compatible": - from .providers.openai_compatible_provider import call as compatible_call - return await compatible_call( - payload_messages, - provider_id=provider_id, role=_slot, - api_key=api_key, - model_override=spec["model"], - max_tokens=max_tokens, - use_tools=use_tools, - reasoning_effort=reasoning_effort, + from .providers import openai_compatible_provider + return await openai_compatible_provider.call( + payload_messages, provider_id=provider_id, role=_slot, + api_key=api_key, model_override=spec["model"], max_tokens=max_tokens, + use_tools=use_tools, reasoning_effort=reasoning_effort, + pin_model_override=pin_requested_provider, progress_callback=progress_callback, ) @@ -596,4 +591,4 @@ async def _call_anthropic( ) -# 399:120 0:0 16:15 +# 398:129 0:0 16:15 diff --git a/python/services/providers/__init__.py b/python/services/providers/__init__.py index bd5eb4988..74d5e028c 100644 --- a/python/services/providers/__init__.py +++ b/python/services/providers/__init__.py @@ -1,4 +1,4 @@ -# 2:18 0:0 0:1 +# 20:18 0:0 0:1 """providers — one module per upstream LLM API. Each provider module exposes: @@ -20,7 +20,30 @@ async def call( already know the model id and just want SDK delivery; new callers should pass `role` instead and let the resolver pick. """ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + from ._resolver import resolve_model_for_role -__all__ = ["resolve_model_for_role"] -# 2:18 0:0 0:1 + +def _load_versioned_module(public_name: str, filename: str): + qualified_name = f"{__name__}.{public_name}" + existing = sys.modules.get(qualified_name) + if existing is not None: + return existing + spec = spec_from_file_location(qualified_name, Path(__file__).with_name(filename)) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {qualified_name} from {filename}") + module = module_from_spec(spec) + sys.modules[qualified_name] = module + spec.loader.exec_module(module) + return module + + +openai_compatible_provider = _load_versioned_module( + "openai_compatible_provider", "open_comp_prov_v0.0.0alpha.py" +) + +__all__ = ["openai_compatible_provider", "resolve_model_for_role"] +# 20:18 0:0 0:1 diff --git a/python/services/providers/openai_compatible_provider.py b/python/services/providers/open_comp_prov_v0.0.0alpha.py similarity index 97% rename from python/services/providers/openai_compatible_provider.py rename to python/services/providers/open_comp_prov_v0.0.0alpha.py index 7013378b2..7719843ea 100644 --- a/python/services/providers/openai_compatible_provider.py +++ b/python/services/providers/open_comp_prov_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 329:49 0:0 2:5 +# 338:55 0:0 2:5 """Generic OpenAI-compatible provider transport. Provider identity, endpoint, credential name, model, API family, reasoning @@ -20,7 +20,7 @@ # network_boundary: external # user_data_boundary: write # admin_only: false -# tests: tests/test_openai_compatible_provider.py +# tests: tests/test_open_comp_prov_v0.0.0alpha.py # rollout: default_enabled # rollback: Revert this module and remove registry entries whose adapter is openai-compatible. # requires: a0_service_providers_resolver, a0_service_tool_executor, a0_service_tool_distill, a0_service_inference, a0_service_energy_registry @@ -371,6 +371,7 @@ async def call( reasoning_effort: Optional[str] = None, temperature: float = 1.0, store: bool = False, + pin_model_override: bool = False, progress_callback: Optional[Callable[[int, int], None]] = None, ) -> tuple[str, dict]: """Dispatch one registry-defined provider through its configured API family.""" @@ -390,7 +391,14 @@ async def call( if not key: raise ValueError(f"{api_key_env} not configured") - model = await resolve_model_for_role(provider_id, role) if model_override is None or model_override == spec.get("model") else model_override + if pin_model_override: + model = model_override or str(spec.get("model") or "").strip() + if not model: + raise ValueError(f"Provider {provider_id!r} has no model to pin") + elif model_override is None or model_override == spec.get("model"): + model = await resolve_model_for_role(provider_id, role) + else: + model = model_override base_url = str(spec.get("base_url") or "").strip() or None effort = _normalize_reasoning_effort(spec, reasoning_effort) api_family = spec.get("api_family", "responses") @@ -431,4 +439,4 @@ async def call( ) finally: reset_caller_provider(caller_provider_token) -# 329:49 0:0 2:5 +# 338:55 0:0 2:5 diff --git a/python/services/providers/openai_provider.py b/python/services/providers/openai_provider.py index bcdaec899..583f64320 100644 --- a/python/services/providers/openai_provider.py +++ b/python/services/providers/openai_provider.py @@ -1,4 +1,4 @@ -# 28:22 0:0 1:1 +# 27:22 0:0 1:1 """Compatibility wrapper for the registry-driven OpenAI provider.""" from __future__ import annotations @@ -15,7 +15,7 @@ # network_boundary: external # user_data_boundary: write # admin_only: false -# tests: tests/test_openai_compatible_provider.py +# tests: tests/test_open_comp_prov_v0.0.0alpha.py # rollout: default_enabled # rollback: Restore the former OpenAI-only Responses implementation. # requires: a0_service_providers_openai_compatible @@ -25,7 +25,7 @@ from typing import Optional -from .openai_compatible_provider import call as _compatible_call +from . import openai_compatible_provider async def call( @@ -41,7 +41,7 @@ async def call( store: bool = False, ) -> tuple[str, dict]: """Run the built-in OpenAI provider through the shared transport.""" - return await _compatible_call( + return await openai_compatible_provider.call( messages, provider_id="openai", role=role, @@ -53,4 +53,4 @@ async def call( temperature=temperature, store=store, ) -# 28:22 0:0 1:1 +# 27:22 0:0 1:1 diff --git a/python/tests/contract_runner.py b/python/tests/contract_runner.py index a6c01a834..d755e6550 100644 --- a/python/tests/contract_runner.py +++ b/python/tests/contract_runner.py @@ -1,4 +1,4 @@ -# 227:44 0:0 0:0 +# 240:44 0:0 0:0 """Contract/check graph auditor and executor — see test-build/SKILL.md. Source modules own behavioral `CONTRACTS`; test modules own executable @@ -49,6 +49,7 @@ import ast import asyncio +import hashlib import importlib import importlib.util import sys @@ -229,8 +230,20 @@ async def _execute_check(check: Declaration) -> dict[str, Any]: } try: - _target_path, module_name, function_name = _resolve_call_no_exec(check) - module = importlib.import_module(module_name) + target_path, module_name, function_name = _resolve_call_no_exec(check) + if target_path.name.count(".") > 1: + synthetic_name = ( + "_a0_versioned_check_" + + hashlib.sha256(str(target_path).encode("utf-8")).hexdigest()[:16] + ) + spec = importlib.util.spec_from_file_location(synthetic_name, target_path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load versioned check module: {target_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[synthetic_name] = module + spec.loader.exec_module(module) + else: + module = importlib.import_module(module_name) function: Any = getattr(module, function_name) except Exception as exc: return { @@ -317,4 +330,4 @@ async def main() -> int: if __name__ == "__main__": sys.exit(asyncio.run(main())) -# 227:44 0:0 0:0 +# 240:44 0:0 0:0 diff --git a/python/tests/test_openai_compatible_contracts.py b/python/tests/test_open_comp_cont_v0.0.0alpha.py similarity index 58% rename from python/tests/test_openai_compatible_contracts.py rename to python/tests/test_open_comp_cont_v0.0.0alpha.py index f58f1ee71..5d3da853d 100644 --- a/python/tests/test_openai_compatible_contracts.py +++ b/python/tests/test_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 63:10 0:0 0:0 +# 98:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -9,11 +9,21 @@ # timeout: 20 # mutates: none # cleanup: none +# +# id: check_openai_compatible_repair_regressions +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped +# call: self::check_openai_compatible_repair_regressions +# requires: python3, pytest +# timeout: 60 +# mutates: none +# cleanup: none # === END CHECKS === import asyncio import os from pathlib import Path +import subprocess +import sys from types import SimpleNamespace from unittest.mock import patch @@ -33,7 +43,7 @@ def model_dump(self) -> dict: def check_openai_compatible_registry_wiring() -> None: from a0.adapters import openai_compatible_adapter as standalone_adapter - from a0.provider_registry import resolve_openai_compatible_provider + from a0 import resolve_openai_compatible_provider from python.services.providers import openai_compatible_provider as service_adapter root = Path(__file__).resolve().parents[2] @@ -89,6 +99,43 @@ async def run_service_call(): assert captured["async_request"]["model"] == "deepseek-v4-pro" +def check_openai_compatible_repair_regressions() -> None: + """Execute the focused pytest witnesses claimed by this CHECKS block.""" + + root = Path(__file__).resolve().parents[2] + provider_tests = "tests/test_open_comp_prov_v0.0.0alpha.py" + routing_tests = "tests/test_open_comp_rout_v0.0.0alpha.py" + adapter_tests = "tests/test_aone_open_comp_adap_v0.0.0alpha.py" + nodes = [ + f"{provider_tests}::test_repeat_fingerprint_excludes_volatile_transport_ids", + f"{provider_tests}::test_stateless_openai_reasoning_requests_encrypted_state", + f"{provider_tests}::test_first_responses_tool_call_executes_before_repeat_detection", + f"{provider_tests}::test_responses_transport_uses_registry_base_url_model_and_effort", + f"{provider_tests}::test_transport_redacts_configured_key_from_outward_error", + f"{routing_tests}::test_inference_dispatches_adapter_field_without_database", + f"{routing_tests}::test_fanout_bridge_pins_each_requested_provider", + f"{routing_tests}::test_call_model_pins_the_explicit_model_provider", + f"{routing_tests}::test_explicit_model_pin_ignores_cross_tier_role_override", + f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", + ] + environment = dict(os.environ) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + *nodes, + ], + cwd=root, + env=environment, + check=True, + ) + + def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 63:10 0:0 0:0 +# 98:19 0:0 0:0 diff --git a/repl_nix_workspace.egg-info/SOURCES.txt b/repl_nix_workspace.egg-info/SOURCES.txt index f702a006c..2f2a4d2ec 100644 --- a/repl_nix_workspace.egg-info/SOURCES.txt +++ b/repl_nix_workspace.egg-info/SOURCES.txt @@ -113,7 +113,7 @@ python/services/providers/__init__.py python/services/providers/_resolver.py python/services/providers/claude_provider.py python/services/providers/gemini_provider.py -python/services/providers/openai_compatible_provider.py +python/services/providers/open_comp_prov_v0.0.0alpha.py python/services/providers/openai_provider.py python/services/providers/xai_provider.py python/services/tools/__init__.py @@ -145,7 +145,7 @@ python/tests/contract_runner.py python/tests/test_coherence_primes.py python/tests/test_contract_runner.py python/tests/test_encoder_compiles.py -python/tests/test_openai_compatible_contracts.py +python/tests/test_open_comp_cont_v0.0.0alpha.py python/tests/test_platonic_agent.py python/tests/test_ptcna_state.py python/tests/test_runtime_readiness.py @@ -167,8 +167,8 @@ repl_nix_workspace.egg-info/SOURCES.txt repl_nix_workspace.egg-info/dependency_links.txt repl_nix_workspace.egg-info/requires.txt repl_nix_workspace.egg-info/top_level.txt -tests/test_a0_openai_compatible_adapter.py tests/test_a0_package.py +tests/test_aone_open_comp_adap_v0.0.0alpha.py tests/test_artifacts.py tests/test_compute_transcript_full.py tests/test_cut_modes.py @@ -177,7 +177,8 @@ tests/test_hmmm_boundary.py tests/test_inference_modes_usage.py tests/test_interdependent_bootstrap.py tests/test_live_server.py -tests/test_openai_compatible_provider.py +tests/test_open_comp_prov_v0.0.0alpha.py +tests/test_open_comp_rout_v0.0.0alpha.py tests/test_openai_policy_hmmm.py tests/test_route_imports.py tests/test_run_context.py diff --git a/suggest.md b/suggest.md index 3996dbc32..6e984d147 100644 --- a/suggest.md +++ b/suggest.md @@ -145,7 +145,7 @@ Update `OutputEnvelope.gaming_alerts` type and the JSON schema in `io/schemas.py **File:** `a0/adapters/gemini_adapter.py` -**Problem:** The file is empty. The adapter `Protocol` in `model_adapter.py` defines the interface. OpenAI-compatible providers are now implemented through `openai_compatible_adapter.py`; Gemini still lacks a native standalone adapter. +**Problem:** The file is empty. The adapter `Protocol` in `model_adapter.py` defines the interface. OpenAI-compatible providers are now implemented through `open_comp_adap_v0.0.0alpha.py`; Gemini still lacks a native standalone adapter. **Minimum viable implementation:** add a Gemini `ModelAdapter`, then extend the registry-driven selection without reintroducing hard-coded provider branches. diff --git a/tests/test_a0_openai_compatible_adapter.py b/tests/test_aone_open_comp_adap_v0.0.0alpha.py similarity index 76% rename from tests/test_a0_openai_compatible_adapter.py rename to tests/test_aone_open_comp_adap_v0.0.0alpha.py index 5efb3046f..8b760e61a 100644 --- a/tests/test_a0_openai_compatible_adapter.py +++ b/tests/test_aone_open_comp_adap_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 89:1 0:0 0:0 +# 109:1 0:0 0:0 """Standalone a0 selection and OpenAI-compatible adapter contracts.""" from types import SimpleNamespace @@ -18,7 +18,7 @@ def model_dump(self) -> dict: def test_registry_auto_selects_flash_and_explicitly_selects_pro( monkeypatch: pytest.MonkeyPatch, ) -> None: - from a0.provider_registry import resolve_openai_compatible_provider + from a0 import resolve_openai_compatible_provider monkeypatch.delenv("A0_PROVIDER", raising=False) monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") @@ -35,7 +35,7 @@ def test_registry_auto_selects_flash_and_explicitly_selects_pro( def test_explicit_unknown_and_noncompatible_provider_fail_closed( monkeypatch: pytest.MonkeyPatch, ) -> None: - from a0.provider_registry import resolve_openai_compatible_provider + from a0 import resolve_openai_compatible_provider monkeypatch.setenv("A0_PROVIDER", "missing-provider") with pytest.raises(ValueError, match="Unknown A0_PROVIDER"): @@ -50,7 +50,7 @@ def test_adapter_uses_registry_transport_and_sanitizes_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: from a0.adapters import openai_compatible_adapter as adapter_module - from a0.provider_registry import resolve_openai_compatible_provider + from a0 import resolve_openai_compatible_provider captured: dict = {} @@ -90,6 +90,28 @@ def fail_with_secret(**request): assert caught.value.__cause__ is None assert "test-secret" not in str(caught.value) + monkeypatch.setenv("OPENAI_API_KEY", "standalone-openai-secret") + monkeypatch.setenv("A0_PROVIDER", "openai") + provider_id, spec = resolve_openai_compatible_provider() + openai_adapter = adapter_module.OpenAICompatibleAdapter(provider_id, spec) + openai_adapter.complete([{"role": "user", "content": "private by default"}]) + assert captured["request"]["store"] is False + openai_adapter.complete( + [{"role": "user", "content": "explicit retention"}], store=True + ) + assert captured["request"]["store"] is True + + monkeypatch.setenv("OPENAI_API_KEY", "openai-test-secret") + monkeypatch.setenv("A0_PROVIDER", "openai") + provider_id, spec = resolve_openai_compatible_provider() + openai_adapter = adapter_module.OpenAICompatibleAdapter(provider_id, spec) + openai_adapter.complete([{"role": "user", "content": "private by default"}]) + assert captured["request"]["store"] is False + openai_adapter.complete( + [{"role": "user", "content": "explicit storage"}], store=True + ) + assert captured["request"]["store"] is True + def test_router_prefers_configured_generic_adapter( monkeypatch: pytest.MonkeyPatch, @@ -122,4 +144,4 @@ def test_explicit_provider_missing_key_fails_closed( with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): _select_adapter(request) -# 89:1 0:0 0:0 +# 109:1 0:0 0:0 diff --git a/tests/test_openai_compatible_provider.py b/tests/test_open_comp_prov_v0.0.0alpha.py similarity index 66% rename from tests/test_openai_compatible_provider.py rename to tests/test_open_comp_prov_v0.0.0alpha.py index f9973518d..9985b3be3 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_open_comp_prov_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 391:1 0:0 0:0 +# 325:1 0:0 0:0 """Contract tests for registry-driven OpenAI-compatible providers.""" from pathlib import Path @@ -47,9 +47,9 @@ def test_repeat_fingerprint_excludes_volatile_transport_ids() -> None: def test_stateless_openai_reasoning_requests_encrypted_state() -> None: - from python.services.providers.openai_compatible_provider import _responses_kwargs + from python.services.providers import openai_compatible_provider - kwargs = _responses_kwargs( + kwargs = openai_compatible_provider._responses_kwargs( model="gpt-test", input_items=[], max_output_tokens=8, temperature=1.0, reasoning_effort="high", store=False, supports_store=True, tools=None, ) @@ -344,6 +344,11 @@ async def create(**request): async def test_transport_redacts_configured_key_from_outward_error( monkeypatch: pytest.MonkeyPatch, ) -> None: + from python.services.tool_distill import ( + get_caller_provider, + reset_caller_provider, + set_caller_provider, + ) from python.services.providers import openai_compatible_provider as provider class FailingAsyncOpenAI: @@ -355,11 +360,16 @@ async def create(**request): monkeypatch.setattr(provider, "AsyncOpenAI", FailingAsyncOpenAI) monkeypatch.setenv("DEEPSEEK_API_KEY", "witness-secret") - content, usage = await provider.call( - [{"role": "user", "content": "hi"}], - provider_id="deepseek", - use_tools=False, - ) + outer_token = set_caller_provider("outer-provider") + try: + content, usage = await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek", + use_tools=False, + ) + assert get_caller_provider() == "outer-provider" + finally: + reset_caller_provider(outer_token) assert "witness-secret" not in content assert "[redacted]" in content @@ -394,195 +404,4 @@ async def create(**request): assert "[redacted]" in content -@pytest.mark.asyncio -async def test_free_catalog_does_not_surface_cross_tier_deepseek_pro( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from python.services import energy_registry, model_catalog - - async def free_tier(user_id): - return "free" - - async def active_provider(): - return "deepseek" - - monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") - monkeypatch.setattr(model_catalog, "_user_tier", free_tier) - monkeypatch.setattr(energy_registry, "active_provider", active_provider) - - catalog = await model_catalog.list_models_for_user(None) - flash = next(item for item in catalog["providers"] if item["provider_id"] == "deepseek") - pro = next(item for item in catalog["providers"] if item["provider_id"] == "deepseek-pro") - - assert "deepseek-v4-pro" not in {item["model_id"] for item in flash["models"]} - assert pro["tier_blocked"] is True - - -@pytest.mark.asyncio -async def test_openai_wrapper_preserves_reasoning_and_store_contract( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from python.services.providers import openai_provider - - captured: dict = {} - - async def fake_compatible_call(messages, **kwargs): - captured.update(kwargs) - return "openai-ok", {} - - monkeypatch.setattr(openai_provider, "_compatible_call", fake_compatible_call) - content, usage = await openai_provider.call( - [{"role": "user", "content": "hi"}], - api_key="test-openai", - model_override="gpt-test", - reasoning_effort="high", - store=True, - ) - - assert (content, usage) == ("openai-ok", {}) - assert captured["provider_id"] == "openai" - assert captured["reasoning_effort"] == "high" - assert captured["store"] is True - - -@pytest.mark.asyncio -async def test_inference_dispatches_adapter_field_without_database( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from python.services import inference, openai_router - from python.services.providers import openai_compatible_provider as provider - - _clear_provider_keys(monkeypatch) - monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") - monkeypatch.delenv("DATABASE_URL", raising=False) - monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") - captured: dict = {} - - async def fake_call(messages, **kwargs): - captured.update(kwargs) - return "routed", {"total_tokens": 1} - - monkeypatch.setattr(provider, "call", fake_call) - content, usage = await inference.call_provider( - "deepseek", - [{"role": "user", "content": "hi"}], - max_tokens=8, - use_tools=False, - skip_manifest=True, - ) - - assert content == "routed" - assert usage == {"total_tokens": 1} - assert captured["provider_id"] == "deepseek" - assert captured["model_override"] == "deepseek-v4-flash" - assert captured["role"] == "practice" - - -@pytest.mark.asyncio -async def test_fanout_bridge_pins_each_requested_provider( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from python.services import energy_registry, inference, openai_router - from python.services.providers import openai_compatible_provider as provider - - _clear_provider_keys(monkeypatch) - monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") - monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") - - async def conflicting_slot(_slot: str) -> tuple[str, str]: - return "wrong-slot-memory", "openai" - - async def selected_memory(provider_id: str) -> str: - assert provider_id == "deepseek-pro" - return "selected-provider-memory" - - captured: dict = {} - - async def fake_call(messages, **kwargs): - captured["messages"] = messages - captured.update(kwargs) - return "fanout-ok", {} - - monkeypatch.setattr(inference, "_slot_routing_info", conflicting_slot) - monkeypatch.setattr(inference, "_instance_memory_block", selected_memory) - monkeypatch.setattr(provider, "call", fake_call) - - content = await energy_registry._aimmh_call_fn( - "deepseek-pro", - [{"role": "user", "content": "practice this"}], - ) - - assert content == "fanout-ok" - assert captured["provider_id"] == "deepseek-pro" - system_text = captured["messages"][0]["content"] - assert "selected-provider-memory" in system_text - assert "wrong-slot-memory" not in system_text - - -@pytest.mark.asyncio -async def test_call_model_pins_the_explicit_model_provider( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from python.services import call_fn, inference, openai_router - from python.services.providers import openai_compatible_provider as provider - - _clear_provider_keys(monkeypatch) - monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") - monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") - - async def conflicting_slot(_slot: str) -> tuple[str, str]: - return "wrong-slot-memory", "deepseek-pro" - - async def selected_memory(provider_id: str) -> str: - assert provider_id == "deepseek" - return "flash-memory" - - captured: dict = {} - - async def fake_call(messages, **kwargs): - captured["messages"] = messages - captured.update(kwargs) - return "single-ok", {} - - monkeypatch.setattr(inference, "_slot_routing_info", conflicting_slot) - monkeypatch.setattr(inference, "_instance_memory_block", selected_memory) - monkeypatch.setattr(provider, "call", fake_call) - - content, _ = await call_fn.call_model( - "deepseek-v4-flash", - [{"role": "user", "content": "practice this"}], - enforce_tier=False, - enforce_enabled=False, - ) - - assert content == "single-ok" - assert captured["provider_id"] == "deepseek" - system_text = captured["messages"][0]["content"] - assert "flash-memory" in system_text - assert "wrong-slot-memory" not in system_text - - -@pytest.mark.asyncio -async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from python.services.energy_registry import estimate_cost, get_model_pricing - from python.services.model_catalog import resolve_model_id - from python.services.providers import openai_compatible_provider as provider - - _clear_provider_keys(monkeypatch) - provider_id, spec = await resolve_model_id("deepseek-v4-pro") - assert provider_id == "deepseek-pro" - assert spec["model"] == "deepseek-v4-pro" - assert get_model_pricing("deepseek", "deepseek-v4-flash")["input_per_1m"] == 0.44 - assert estimate_cost( - "deepseek", 1_000_000, 1_000_000, model="deepseek-v4-flash" - ) == pytest.approx(1.76) - - with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): - await provider.call( - [{"role": "user", "content": "hi"}], - provider_id="deepseek", - use_tools=False, - ) -# 391:1 0:0 0:0 +# 325:1 0:0 0:0 diff --git a/tests/test_open_comp_rout_v0.0.0alpha.py b/tests/test_open_comp_rout_v0.0.0alpha.py new file mode 100644 index 000000000..9812fc7a4 --- /dev/null +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -0,0 +1,260 @@ +# 198:1 0:0 0:0 +"""Routing and catalog tests for registry-driven compatible providers.""" + +from types import SimpleNamespace + +import pytest + + +class _Dump: + def __init__(self, data: dict) -> None: + self._data = data + self.output_text = "" + + def model_dump(self) -> dict: + return self._data + + +def _clear_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None: + from python.services.energy_registry import BUILTIN_PROVIDERS + + for spec in BUILTIN_PROVIDERS.values(): + api_key_env = spec.get("api_key_env") + if api_key_env: + monkeypatch.delenv(api_key_env, raising=False) + + +@pytest.mark.asyncio +async def test_free_catalog_does_not_surface_cross_tier_deepseek_pro( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import energy_registry, model_catalog + + async def free_tier(user_id): + return "free" + + async def active_provider(): + return "deepseek" + + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setattr(model_catalog, "_user_tier", free_tier) + monkeypatch.setattr(energy_registry, "active_provider", active_provider) + + catalog = await model_catalog.list_models_for_user(None) + flash = next(item for item in catalog["providers"] if item["provider_id"] == "deepseek") + pro = next(item for item in catalog["providers"] if item["provider_id"] == "deepseek-pro") + + assert "deepseek-v4-pro" not in {item["model_id"] for item in flash["models"]} + assert pro["tier_blocked"] is True + + +@pytest.mark.asyncio +async def test_openai_wrapper_preserves_reasoning_and_store_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.providers import openai_provider + + captured: dict = {} + + async def fake_compatible_call(messages, **kwargs): + captured.update(kwargs) + return "openai-ok", {} + + monkeypatch.setattr( + openai_provider.openai_compatible_provider, "call", fake_compatible_call + ) + content, usage = await openai_provider.call( + [{"role": "user", "content": "hi"}], + api_key="test-openai", + model_override="gpt-test", + reasoning_effort="high", + store=True, + ) + + assert (content, usage) == ("openai-ok", {}) + assert captured["provider_id"] == "openai" + assert captured["reasoning_effort"] == "high" + assert captured["store"] is True + + +@pytest.mark.asyncio +async def test_inference_dispatches_adapter_field_without_database( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import inference, openai_router + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") + captured: dict = {} + + async def fake_call(messages, **kwargs): + captured.update(kwargs) + return "routed", {"total_tokens": 1} + + monkeypatch.setattr(provider, "call", fake_call) + content, usage = await inference.call_provider( + "deepseek", + [{"role": "user", "content": "hi"}], + max_tokens=8, + use_tools=False, + skip_manifest=True, + ) + + assert content == "routed" + assert usage == {"total_tokens": 1} + assert captured["provider_id"] == "deepseek" + assert captured["model_override"] == "deepseek-v4-flash" + assert captured["role"] == "practice" + + +@pytest.mark.asyncio +async def test_fanout_bridge_pins_each_requested_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import energy_registry, inference, openai_router + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") + + async def conflicting_slot(_slot: str) -> tuple[str, str]: + return "wrong-slot-memory", "openai" + + async def selected_memory(provider_id: str) -> str: + assert provider_id == "deepseek-pro" + return "selected-provider-memory" + + captured: dict = {} + + async def fake_call(messages, **kwargs): + captured["messages"] = messages + captured.update(kwargs) + return "fanout-ok", {} + + monkeypatch.setattr(inference, "_slot_routing_info", conflicting_slot) + monkeypatch.setattr(inference, "_instance_memory_block", selected_memory) + monkeypatch.setattr(provider, "call", fake_call) + + content = await energy_registry._aimmh_call_fn( + "deepseek-pro", + [{"role": "user", "content": "practice this"}], + ) + + assert content == "fanout-ok" + assert captured["provider_id"] == "deepseek-pro" + system_text = captured["messages"][0]["content"] + assert "selected-provider-memory" in system_text + assert "wrong-slot-memory" not in system_text + + +@pytest.mark.asyncio +async def test_call_model_pins_the_explicit_model_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import call_fn, inference, openai_router + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") + + async def conflicting_slot(_slot: str) -> tuple[str, str]: + return "wrong-slot-memory", "deepseek-pro" + + async def selected_memory(provider_id: str) -> str: + assert provider_id == "deepseek" + return "flash-memory" + + captured: dict = {} + + async def fake_call(messages, **kwargs): + captured["messages"] = messages + captured.update(kwargs) + return "single-ok", {} + + monkeypatch.setattr(inference, "_slot_routing_info", conflicting_slot) + monkeypatch.setattr(inference, "_instance_memory_block", selected_memory) + monkeypatch.setattr(provider, "call", fake_call) + + content, _ = await call_fn.call_model( + "deepseek-v4-flash", + [{"role": "user", "content": "practice this"}], + enforce_tier=False, + enforce_enabled=False, + ) + + assert content == "single-ok" + assert captured["provider_id"] == "deepseek" + assert captured["pin_model_override"] is True + system_text = captured["messages"][0]["content"] + assert "flash-memory" in system_text + assert "wrong-slot-memory" not in system_text + + +@pytest.mark.asyncio +async def test_explicit_model_pin_ignores_cross_tier_role_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.providers import openai_compatible_provider as provider + + captured: dict = {} + + class FakeAsyncOpenAI: + def __init__(self, **kwargs): + async def create(**request): + captured.update(request) + return _Dump({ + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "pinned"}], + }], + "usage": {}, + }) + + self.responses = SimpleNamespace(create=create) + + monkeypatch.setattr(provider, "AsyncOpenAI", FakeAsyncOpenAI) + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + monkeypatch.setenv("DEEPSEEK_MODEL_PRACTICE", "deepseek-v4-pro") + + content, _ = await provider.call( + [{"role": "user", "content": "practice this"}], + provider_id="deepseek", + role="practice", + model_override="deepseek-v4-flash", + pin_model_override=True, + use_tools=False, + ) + + assert content == "pinned" + assert captured["model"] == "deepseek-v4-flash" + + +@pytest.mark.asyncio +async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.energy_registry import estimate_cost, get_model_pricing + from python.services.model_catalog import resolve_model_id + from python.services.providers import openai_compatible_provider as provider + + _clear_provider_keys(monkeypatch) + provider_id, spec = await resolve_model_id("deepseek-v4-pro") + assert provider_id == "deepseek-pro" + assert spec["model"] == "deepseek-v4-pro" + assert get_model_pricing("deepseek", "deepseek-v4-flash")["input_per_1m"] == 0.44 + assert estimate_cost( + "deepseek", 1_000_000, 1_000_000, model="deepseek-v4-flash" + ) == pytest.approx(1.76) + + with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): + await provider.call( + [{"role": "user", "content": "hi"}], + provider_id="deepseek", + use_tools=False, + ) +# 198:1 0:0 0:0 From 11278b7c340ac50f8285eba9572e575274b47d69 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 15:05:22 -0700 Subject: [PATCH 12/48] fix: close exact-head provider review findings --- .github/workflows/deploy.yml | 4 +- a0/adapters/open_comp_adap_v0.0.0alpha.py | 14 ++- a0_msdmd.ts | 93 +++++++++++++------ cloudbuild.yaml | 4 +- python/routes/chat.py | 23 ++++- python/services/energy_registry.py | 21 +++-- ....py => chec_open_comp_cont_v0.0.0alpha.py} | 8 +- python/tests/contract_runner.py | 26 +++++- repl_nix_workspace.egg-info/SOURCES.txt | 4 +- tests/test_aone_open_comp_adap_v0.0.0alpha.py | 13 ++- tests/test_open_comp_rout_v0.0.0alpha.py | 49 +++++++++- 11 files changed, 198 insertions(+), 61 deletions(-) rename python/tests/{test_open_comp_cont_v0.0.0alpha.py => chec_open_comp_cont_v0.0.0alpha.py} (93%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45460ac4c..4db9de500 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,7 +38,9 @@ jobs: run: uv sync --frozen - name: Run provider contracts - run: uv run pytest -q tests/test_open_comp_prov_v0.0.0alpha.py tests/test_open_comp_rout_v0.0.0alpha.py tests/test_aone_open_comp_adap_v0.0.0alpha.py tests/test_a0_package.py python/tests/test_open_comp_cont_v0.0.0alpha.py + run: | + uv run pytest -q tests/test_open_comp_prov_v0.0.0alpha.py tests/test_open_comp_rout_v0.0.0alpha.py tests/test_aone_open_comp_adap_v0.0.0alpha.py tests/test_a0_package.py + uv run python -m python.tests.contract_runner --only check_openai_compatible_registry_wiring --only check_openai_compatible_repair_regressions check-console-tabs: name: Console tab regression guard diff --git a/a0/adapters/open_comp_adap_v0.0.0alpha.py b/a0/adapters/open_comp_adap_v0.0.0alpha.py index 969b97b64..3d79b1e62 100644 --- a/a0/adapters/open_comp_adap_v0.0.0alpha.py +++ b/a0/adapters/open_comp_adap_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 98:41 0:0 2:0 +# 96:41 0:0 2:0 """Synchronous standalone adapter for registry-defined OpenAI-compatible APIs.""" from __future__ import annotations @@ -104,6 +104,10 @@ def __init__(self, provider_id: str, spec: dict[str, Any]) -> None: def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: effort = _normalize_effort(self.spec, kwargs.get("reasoning_effort")) max_tokens = int(kwargs.get("max_tokens") or 4096) + if self.api_family not in {"responses", "chat_completions"}: + raise ValueError( + f"Provider {self.provider_id!r} has unsupported api_family={self.api_family!r}" + ) try: if self.api_family == "responses": request: dict[str, Any] = { @@ -134,12 +138,6 @@ def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: choices = data.get("choices") or [] message = (choices[0].get("message") if choices else None) or {} text = str(message.get("content") or "") - else: - raise ValueError( - f"Provider {self.provider_id!r} has unsupported api_family={self.api_family!r}" - ) - except ValueError: - raise except Exception as exc: raise RuntimeError( f"{self.provider_id} request failed: {type(exc).__name__}" @@ -154,4 +152,4 @@ def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: }, "subagents_used": [], } -# 98:41 0:0 2:0 +# 96:41 0:0 2:0 diff --git a/a0_msdmd.ts b/a0_msdmd.ts index 1a2d5edc8..c79a9c570 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,4 +1,4 @@ -// 7992:0 0:1 0:1 +// 8027:0 0:1 0:1 import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; export default defineMsdmdCollection({ @@ -846,6 +846,16 @@ export default defineMsdmdCollection({ "file": "python/routes/billing.py", "id": "billing_webhook_replay_idempotent" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "a provider-pinned single-model call stops at an approval gate", + "then": "both gate-id and scope approval replays retain that exact provider pin, including any subsequently pending gate" + }, + "file": "python/routes/chat.py", + "id": "chat_approval_replay_preserves_provider_pin" + }, { "block": "CONTRACTS", "fields": { @@ -1250,6 +1260,17 @@ export default defineMsdmdCollection({ "file": "python/services/editable_registry.py", "id": "a0_service_editable_registry" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "DeepSeek Flash and a more expensive fallback provider are configured while earlier cheap candidates are unavailable", + "since": "2026-09-09", + "then": "cheap_provider selects DeepSeek Flash before registry-order fallback" + }, + "file": "python/services/energy_registry.py", + "id": "cheap_provider_prefers_configured_low_cost_provider" + }, { "block": "MODULE_BUILD", "fields": { @@ -1267,7 +1288,7 @@ export default defineMsdmdCollection({ "since": "2026-06-02", "storage_boundary": "read", "summary": "Energy-provider catalog and pricing/cost layer \u2014 loads provider+pricing JSON data, resolves active/default/cheap providers, and estimates per-call cost and cache breakdown from usage.", - "tests": "hmmm", + "tests": "tests/test_open_comp_rout_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "none" }, @@ -2772,6 +2793,32 @@ export default defineMsdmdCollection({ "file": "python/storage/core.py", "id": "storage_create_owner_isolation" }, + { + "block": "CHECKS", + "fields": { + "call": "self::check_openai_compatible_registry_wiring", + "cleanup": "none", + "mutates": "none", + "proves": "openai_compatible_registry_driven, a0_provider_selection, a0_openai_compatible_completion", + "requires": "python3", + "timeout": "20" + }, + "file": "python/tests/chec_open_comp_cont_v0.0.0alpha.py", + "id": "check_openai_compatible_registry_wiring" + }, + { + "block": "CHECKS", + "fields": { + "call": "self::check_openai_compatible_repair_regressions", + "cleanup": "none", + "mutates": "none", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin", + "requires": "python3, pytest", + "timeout": "60" + }, + "file": "python/tests/chec_open_comp_cont_v0.0.0alpha.py", + "id": "check_openai_compatible_repair_regressions" + }, { "block": "CONTRACTS", "fields": { @@ -3299,32 +3346,6 @@ export default defineMsdmdCollection({ "file": "python/tests/test_contract_runner.py", "id": "check_contract_graph_rejects_incomplete_linkage" }, - { - "block": "CHECKS", - "fields": { - "call": "self::check_openai_compatible_registry_wiring", - "cleanup": "none", - "mutates": "none", - "proves": "openai_compatible_registry_driven, a0_provider_selection, a0_openai_compatible_completion", - "requires": "python3", - "timeout": "20" - }, - "file": "python/tests/test_open_comp_cont_v0.0.0alpha.py", - "id": "check_openai_compatible_registry_wiring" - }, - { - "block": "CHECKS", - "fields": { - "call": "self::check_openai_compatible_repair_regressions", - "cleanup": "none", - "mutates": "none", - "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped", - "requires": "python3, pytest", - "timeout": "60" - }, - "file": "python/tests/test_open_comp_cont_v0.0.0alpha.py", - "id": "check_openai_compatible_repair_regressions" - }, { "block": "CHECKS", "fields": { @@ -5244,6 +5265,20 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "call_fn_resolved_model_pins_provider" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "chat_approval_replay_preserves_provider_pin" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "cheap_provider_prefers_configured_low_cost_provider" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -7992,4 +8027,4 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 7992:0 0:1 0:1 +// 8027:0 0:1 0:1 diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 386bc8bdb..d030dc4e5 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -34,7 +34,9 @@ jobs: run: pip install -e . pytest pytest-asyncio - name: Run provider contracts - run: pytest -q tests/test_open_comp_prov_v0.0.0alpha.py tests/test_open_comp_rout_v0.0.0alpha.py tests/test_aone_open_comp_adap_v0.0.0alpha.py tests/test_a0_package.py python/tests/test_open_comp_cont_v0.0.0alpha.py + run: | + pytest -q tests/test_open_comp_prov_v0.0.0alpha.py tests/test_open_comp_rout_v0.0.0alpha.py tests/test_aone_open_comp_adap_v0.0.0alpha.py tests/test_a0_package.py + python -m python.tests.contract_runner --only check_openai_compatible_registry_wiring --only check_openai_compatible_repair_regressions check-console-tabs: name: Console tab regression guard diff --git a/python/routes/chat.py b/python/routes/chat.py index 3b361b752..341c06355 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 637:184 2:7 2:16 +# 647:191 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -11,7 +11,7 @@ from ..services.prompt_assembly import build_system_prompt from ..services.bg_tasks import spawn as _spawn_bg -# In-memory pending gate store: conv_id → {gate_id, history, system_prompt, provider_id, uid, ts} +# In-memory pending gate store: conv_id → gate context, including provider pin state. # Used to replay a blocked action when the user grants a scope. # Entries are evicted after _PENDING_GATE_TTL_SECS to keep the map bounded. _pending_gates: dict[int, dict] = {} @@ -483,6 +483,9 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: system_prompt=pending["system_prompt"], user_id=uid or None, skip_approval=True, + pin_requested_provider=bool( + pending.get("pin_requested_provider", False) + ), ) finally: set_approval_scope_user_id(None) @@ -597,6 +600,9 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: messages=pending["history"], system_prompt=pending["system_prompt"], user_id=uid or None, + pin_requested_provider=bool( + pending.get("pin_requested_provider", False) + ), ) finally: set_approval_scope_user_id(None) @@ -609,6 +615,9 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: "history": pending["history"], "system_prompt": pending["system_prompt"], "provider_id": pending["provider_id"], + "pin_requested_provider": bool( + pending.get("pin_requested_provider", False) + ), "uid": uid, # Carry the allow-list forward so subsequent replays # continue to respect the original tool selection. @@ -836,6 +845,9 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: "history": history, "system_prompt": system_prompt or None, "provider_id": provider_id, + # AgentInstance.run delegates to call_model, which pins the + # resolved model/provider after tier and enabled gates. + "pin_requested_provider": True, "uid": uid, # Persist the allow-list so approval replay uses the same tool set. "enabled_tools": list(_conv_tools) if isinstance(_conv_tools, list) else None, @@ -902,5 +914,10 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # active_provider — server-side sources still fall back, only # user input is strict) # class: correctness +# +# id: chat_approval_replay_preserves_provider_pin +# given: a provider-pinned single-model call stops at an approval gate +# then: both gate-id and scope approval replays retain that exact provider pin, including any subsequently pending gate +# class: correctness # === END CONTRACTS === -# 637:184 2:7 2:16 +# 647:191 2:7 2:16 diff --git a/python/services/energy_registry.py b/python/services/energy_registry.py index 8ebdb85ea..8b511c7b3 100644 --- a/python/services/energy_registry.py +++ b/python/services/energy_registry.py @@ -1,4 +1,4 @@ -# 289:88 0:0 20:3 +# 291:95 0:0 20:3 # === MODULE_BUILD === # id: a0_service_energy_registry # module_name: energy_registry @@ -12,22 +12,30 @@ # network_boundary: internal # user_data_boundary: none # admin_only: false -# tests: hmmm +# tests: tests/test_open_comp_rout_v0.0.0alpha.py # rollout: default_enabled # rollback: Revert this file; provider catalog and pricing revert to prior JSON-backed definitions. # requires: none # since: 2026-06-02 # unresolved: none # === END MODULE_BUILD === -import logging + +# === CONTRACTS === +# id: cheap_provider_prefers_configured_low_cost_provider +# given: DeepSeek Flash and a more expensive fallback provider are configured while earlier cheap candidates are unavailable +# then: cheap_provider selects DeepSeek Flash before registry-order fallback +# class: correctness +# since: 2026-09-09 +# === END CONTRACTS === import contextvars import json +import logging import os -logger = logging.getLogger(__name__) - from pathlib import Path from typing import Optional +logger = logging.getLogger(__name__) + # Provider catalog and per-provider optimizer presets live as JSON data, not # code literals (doctrine: no executable-data string literals — model slugs, # provider IDs, and capability flags must be edit-without-deploy values). @@ -145,6 +153,7 @@ async def active_provider() -> str: "grok", # grok-4-fast-reasoning $0.20/1M "gemini", # gemini-2.5-flash $0.30/1M "openai", # gpt-5-mini $0.25/1M + "deepseek", # deepseek-v4-flash $0.44/1M ] @@ -433,4 +442,4 @@ async def resolve_providers(providers: list[str] | None) -> list[str]: elif p in BUILTIN_PROVIDERS and p not in out: out.append(p) return out -# 289:88 0:0 20:3 +# 291:95 0:0 20:3 diff --git a/python/tests/test_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py similarity index 93% rename from python/tests/test_open_comp_cont_v0.0.0alpha.py rename to python/tests/chec_open_comp_cont_v0.0.0alpha.py index 5d3da853d..fabf9313d 100644 --- a/python/tests/test_open_comp_cont_v0.0.0alpha.py +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 98:19 0:0 0:0 +# 100:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -11,7 +11,7 @@ # cleanup: none # # id: check_openai_compatible_repair_regressions -# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin # call: self::check_openai_compatible_repair_regressions # requires: python3, pytest # timeout: 60 @@ -116,6 +116,8 @@ def check_openai_compatible_repair_regressions() -> None: f"{routing_tests}::test_fanout_bridge_pins_each_requested_provider", f"{routing_tests}::test_call_model_pins_the_explicit_model_provider", f"{routing_tests}::test_explicit_model_pin_ignores_cross_tier_role_override", + f"{routing_tests}::test_cheap_provider_prefers_deepseek_before_expensive_fallback", + f"{routing_tests}::test_approval_replays_preserve_explicit_provider_pin", f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", ] environment = dict(os.environ) @@ -138,4 +140,4 @@ def check_openai_compatible_repair_regressions() -> None: def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 98:19 0:0 0:0 +# 100:19 0:0 0:0 diff --git a/python/tests/contract_runner.py b/python/tests/contract_runner.py index d755e6550..3ce23ee33 100644 --- a/python/tests/contract_runner.py +++ b/python/tests/contract_runner.py @@ -1,4 +1,4 @@ -# 240:44 0:0 0:0 +# 257:44 0:0 0:0 """Contract/check graph auditor and executor — see test-build/SKILL.md. Source modules own behavioral `CONTRACTS`; test modules own executable @@ -47,6 +47,7 @@ # === END CONTRACTS === from __future__ import annotations +import argparse import ast import asyncio import hashlib @@ -282,7 +283,7 @@ def _rel(path: Path) -> str: return str(path) -async def main() -> int: +async def main(only: set[str] | None = None) -> int: contracts, uncovered_modules = _source_declarations() checks = _check_declarations() effective_checks, gaps, warnings = audit_graph(contracts, checks) @@ -299,6 +300,14 @@ async def main() -> int: print(f"\n{len(gaps)} graph gap(s); no checks executed") return 1 + if only: + known_ids = {check.id for check in effective_checks} + unknown_ids = sorted(only - known_ids) + if unknown_ids: + print(f"\nunknown check id(s): {', '.join(unknown_ids)}") + return 1 + effective_checks = [check for check in effective_checks if check.id in only] + results: list[dict[str, Any]] = [] print(f"\nexecuting {len(effective_checks)} checks\n") for check in effective_checks: @@ -329,5 +338,14 @@ async def main() -> int: if __name__ == "__main__": - sys.exit(asyncio.run(main())) -# 240:44 0:0 0:0 + parser = argparse.ArgumentParser() + parser.add_argument( + "--only", + action="append", + default=[], + metavar="CHECK_ID", + help="execute only this check after auditing the complete declaration graph", + ) + args = parser.parse_args() + sys.exit(asyncio.run(main(set(args.only) or None))) +# 257:44 0:0 0:0 diff --git a/repl_nix_workspace.egg-info/SOURCES.txt b/repl_nix_workspace.egg-info/SOURCES.txt index 2f2a4d2ec..2d120a27c 100644 --- a/repl_nix_workspace.egg-info/SOURCES.txt +++ b/repl_nix_workspace.egg-info/SOURCES.txt @@ -145,7 +145,7 @@ python/tests/contract_runner.py python/tests/test_coherence_primes.py python/tests/test_contract_runner.py python/tests/test_encoder_compiles.py -python/tests/test_open_comp_cont_v0.0.0alpha.py +python/tests/chec_open_comp_cont_v0.0.0alpha.py python/tests/test_platonic_agent.py python/tests/test_ptcna_state.py python/tests/test_runtime_readiness.py @@ -185,4 +185,4 @@ tests/test_run_context.py tests/test_skills.py tests/test_smoke.py tests/test_spawn_caps.py -tests/test_tools_registry.py \ No newline at end of file +tests/test_tools_registry.py diff --git a/tests/test_aone_open_comp_adap_v0.0.0alpha.py b/tests/test_aone_open_comp_adap_v0.0.0alpha.py index 8b760e61a..d786fae98 100644 --- a/tests/test_aone_open_comp_adap_v0.0.0alpha.py +++ b/tests/test_aone_open_comp_adap_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 109:1 0:0 0:0 +# 116:1 0:0 0:0 """Standalone a0 selection and OpenAI-compatible adapter contracts.""" from types import SimpleNamespace @@ -90,6 +90,15 @@ def fail_with_secret(**request): assert caught.value.__cause__ is None assert "test-secret" not in str(caught.value) + def value_error_with_secret(**request): + raise ValueError("upstream echoed test-secret") + + adapter._client.responses.create = value_error_with_secret + with pytest.raises(RuntimeError, match="deepseek-pro request failed") as caught: + adapter.complete([{"role": "user", "content": "fail-value"}]) + assert caught.value.__cause__ is None + assert "test-secret" not in str(caught.value) + monkeypatch.setenv("OPENAI_API_KEY", "standalone-openai-secret") monkeypatch.setenv("A0_PROVIDER", "openai") provider_id, spec = resolve_openai_compatible_provider() @@ -144,4 +153,4 @@ def test_explicit_provider_missing_key_fails_closed( with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): _select_adapter(request) -# 109:1 0:0 0:0 +# 116:1 0:0 0:0 diff --git a/tests/test_open_comp_rout_v0.0.0alpha.py b/tests/test_open_comp_rout_v0.0.0alpha.py index 9812fc7a4..668a6064e 100644 --- a/tests/test_open_comp_rout_v0.0.0alpha.py +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -1,6 +1,8 @@ -# 198:1 0:0 0:0 +# 236:1 0:0 0:0 """Routing and catalog tests for registry-driven compatible providers.""" +import ast +from pathlib import Path from types import SimpleNamespace import pytest @@ -24,6 +26,49 @@ def _clear_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(api_key_env, raising=False) +def test_cheap_provider_prefers_deepseek_before_expensive_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services.energy_registry import cheap_provider + + _clear_provider_keys(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "expensive-secret") + monkeypatch.setenv("DEEPSEEK_API_KEY", "cheap-secret") + + assert cheap_provider() == "deepseek" + + +def test_approval_replays_preserve_explicit_provider_pin() -> None: + root = Path(__file__).resolve().parents[1] + tree = ast.parse((root / "python/routes/chat.py").read_text(encoding="utf-8")) + replay_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "call_provider" + ] + assert len(replay_calls) == 2 + assert all( + any(keyword.arg == "pin_requested_provider" for keyword in call.keywords) + for call in replay_calls + ) + + pending_writes = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_store_pending_gate" + ] + assert len(pending_writes) == 2 + for call in pending_writes: + entry = call.args[1] + assert isinstance(entry, ast.Dict) + keys = {key.value for key in entry.keys if isinstance(key, ast.Constant)} + assert "pin_requested_provider" in keys + + @pytest.mark.asyncio async def test_free_catalog_does_not_surface_cross_tier_deepseek_pro( monkeypatch: pytest.MonkeyPatch, @@ -257,4 +302,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 198:1 0:0 0:0 +# 236:1 0:0 0:0 From d40e4f737e5975f223b55db473a780bd1292a62b Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 15:22:31 -0700 Subject: [PATCH 13/48] fix: retain role routing for auto chat turns --- a0_msdmd.ts | 24 ++++++++-- python/routes/chat.py | 10 ++-- python/services/agent_instance.py | 6 ++- python/services/call_fn.py | 13 +++-- .../tests/chec_open_comp_cont_v0.0.0alpha.py | 7 +-- tests/test_open_comp_rout_v0.0.0alpha.py | 47 ++++++++++++++++++- 6 files changed, 89 insertions(+), 18 deletions(-) diff --git a/a0_msdmd.ts b/a0_msdmd.ts index c79a9c570..0d5836785 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,4 +1,4 @@ -// 8027:0 0:1 0:1 +// 8045:0 0:1 0:1 import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; export default defineMsdmdCollection({ @@ -1069,6 +1069,17 @@ export default defineMsdmdCollection({ "file": "python/services/bg_tasks.py", "id": "a0_service_bg_tasks" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "a caller marks its seed model as auto-selected rather than explicit", + "since": "2026-09-09", + "then": "call_model leaves the resolved provider unpinned so inference may route by the classified role slot" + }, + "file": "python/services/call_fn.py", + "id": "call_fn_auto_model_keeps_role_slot_routing" + }, { "block": "CONTRACTS", "fields": { @@ -2812,7 +2823,7 @@ export default defineMsdmdCollection({ "call": "self::check_openai_compatible_repair_regressions", "cleanup": "none", "mutates": "none", - "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin", "requires": "python3, pytest", "timeout": "60" }, @@ -5258,6 +5269,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "a0_openai_compatible_store_defaults_off" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "call_fn_auto_model_keeps_role_slot_routing" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -8027,4 +8045,4 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 8027:0 0:1 0:1 +// 8045:0 0:1 0:1 diff --git a/python/routes/chat.py b/python/routes/chat.py index 341c06355..dbc64b41a 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 647:191 2:7 2:16 +# 649:189 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -372,6 +372,7 @@ async def send_message(conv_id: int, body: SendMessage, request: Request): model_id = conv.get("model") or "" if not model_id: raise HTTPException(status_code=503, detail="No instantiation selected") + provider_pin_requested = model_from_body or agent_model_id is not None # Resolve model_id → provider_id via the catalog so forge agents # whose model_id is a real model name (e.g. "gpt-5-mini") route # correctly downstream. The fallback below is intentionally @@ -793,6 +794,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: content, usage = await inst.run( history, system_prompt_override=system_prompt or None, + pin_requested_provider=provider_pin_requested, ) print(f"[chat-dbg] provider={inst.provider_id!r} hist_len={len(history)} content_len={len(content or '')} content_preview={repr((content or '')[:80])}") # Use the resolved provider_id from the instance — for forge @@ -845,9 +847,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: "history": history, "system_prompt": system_prompt or None, "provider_id": provider_id, - # AgentInstance.run delegates to call_model, which pins the - # resolved model/provider after tier and enabled gates. - "pin_requested_provider": True, + "pin_requested_provider": provider_pin_requested, "uid": uid, # Persist the allow-list so approval replay uses the same tool set. "enabled_tools": list(_conv_tools) if isinstance(_conv_tools, list) else None, @@ -920,4 +920,4 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # then: both gate-id and scope approval replays retain that exact provider pin, including any subsequently pending gate # class: correctness # === END CONTRACTS === -# 647:191 2:7 2:16 +# 649:189 2:7 2:16 diff --git a/python/services/agent_instance.py b/python/services/agent_instance.py index 5603b3bef..464ef55c8 100644 --- a/python/services/agent_instance.py +++ b/python/services/agent_instance.py @@ -1,4 +1,4 @@ -# 112:67 0:0 3:3 +# 114:67 0:0 3:3 """AgentInstance — runtime handle for "the thing that calls a model". Unifies three concepts that previously each had bespoke plumbing: @@ -95,6 +95,7 @@ async def run( max_tokens: int = 8000, skip_approval: bool = False, reasoning_effort: Optional[str] = None, + pin_requested_provider: bool = True, ) -> tuple[str, dict]: """Send history, return (content, usage). Single seam to the model. @@ -117,6 +118,7 @@ async def run( reasoning_effort=reasoning_effort, enforce_tier=self.enforce_tier, enforce_enabled=self.enforce_enabled, + pin_requested_provider=pin_requested_provider, ) # Cache the resolved provider for downstream persistence/logging. if self.provider_id is None: @@ -196,4 +198,4 @@ def __repr__(self) -> str: f"tools={self.use_tools}, " f"resolved={self.provider_id!r})" ) -# 112:67 0:0 3:3 +# 114:67 0:0 3:3 diff --git a/python/services/call_fn.py b/python/services/call_fn.py index ab0019a1c..b858b12fa 100644 --- a/python/services/call_fn.py +++ b/python/services/call_fn.py @@ -1,4 +1,4 @@ -# 99:73 0:0 3:2 +# 101:86 0:0 3:2 """call_fn — canonical CallFn adapter. aimmh_lib.adapters.make_call_fn pattern, ported to a0p. The CallFn is the @@ -60,6 +60,12 @@ # then: the resolved provider is pinned through inference and cannot be replaced by a prompt role slot # class: correctness # since: 2026-09-09 +# +# id: call_fn_auto_model_keeps_role_slot_routing +# given: a caller marks its seed model as auto-selected rather than explicit +# then: call_model leaves the resolved provider unpinned so inference may route by the classified role slot +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === from typing import Awaitable, Callable, Optional @@ -104,6 +110,7 @@ async def call_model( reasoning_effort: Optional[str] = None, enforce_tier: bool = True, enforce_enabled: bool = True, + pin_requested_provider: bool = True, ) -> tuple[str, dict]: """Module-level full-shape call. Resolves model_id → provider_id, gates on tier + provider-enabled flag, and delegates to call_provider. @@ -140,7 +147,7 @@ async def call_model( user_id=user_id, skip_approval=skip_approval, reasoning_effort=reasoning_effort, - pin_requested_provider=True, + pin_requested_provider=pin_requested_provider, ) return content, usage @@ -204,4 +211,4 @@ async def _call(model_id: str, messages: list[dict], **kwargs) -> str: content, _usage = await full(model_id, messages, **kwargs) return content return _call -# 99:73 0:0 3:2 +# 101:86 0:0 3:2 diff --git a/python/tests/chec_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py index fabf9313d..1a6dc8ba3 100644 --- a/python/tests/chec_open_comp_cont_v0.0.0alpha.py +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 100:19 0:0 0:0 +# 101:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -11,7 +11,7 @@ # cleanup: none # # id: check_openai_compatible_repair_regressions -# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin # call: self::check_openai_compatible_repair_regressions # requires: python3, pytest # timeout: 60 @@ -118,6 +118,7 @@ def check_openai_compatible_repair_regressions() -> None: f"{routing_tests}::test_explicit_model_pin_ignores_cross_tier_role_override", f"{routing_tests}::test_cheap_provider_prefers_deepseek_before_expensive_fallback", f"{routing_tests}::test_approval_replays_preserve_explicit_provider_pin", + f"{routing_tests}::test_call_model_leaves_auto_selected_provider_unpinned", f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", ] environment = dict(os.environ) @@ -140,4 +141,4 @@ def check_openai_compatible_repair_regressions() -> None: def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 100:19 0:0 0:0 +# 101:19 0:0 0:0 diff --git a/tests/test_open_comp_rout_v0.0.0alpha.py b/tests/test_open_comp_rout_v0.0.0alpha.py index 668a6064e..d9c314d0b 100644 --- a/tests/test_open_comp_rout_v0.0.0alpha.py +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 236:1 0:0 0:0 +# 270:1 0:0 0:0 """Routing and catalog tests for registry-driven compatible providers.""" import ast @@ -54,6 +54,18 @@ def test_approval_replays_preserve_explicit_provider_pin() -> None: for call in replay_calls ) + instance_runs = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "run" + ] + assert any( + any(keyword.arg == "pin_requested_provider" for keyword in call.keywords) + for call in instance_runs + ) + pending_writes = [ node for node in ast.walk(tree) @@ -69,6 +81,37 @@ def test_approval_replays_preserve_explicit_provider_pin() -> None: assert "pin_requested_provider" in keys +@pytest.mark.asyncio +async def test_call_model_leaves_auto_selected_provider_unpinned( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import call_fn + + async def resolve_model_id(model_id: str): + assert model_id == "auto-seed" + return "deepseek", {} + + captured: dict = {} + + async def call_provider(**kwargs): + captured.update(kwargs) + return "routed", {} + + monkeypatch.setattr(call_fn, "resolve_model_id", resolve_model_id) + monkeypatch.setattr(call_fn, "call_provider", call_provider) + + content, _ = await call_fn.call_model( + "auto-seed", + [{"role": "user", "content": "practice this"}], + enforce_tier=False, + enforce_enabled=False, + pin_requested_provider=False, + ) + + assert content == "routed" + assert captured["pin_requested_provider"] is False + + @pytest.mark.asyncio async def test_free_catalog_does_not_surface_cross_tier_deepseek_pro( monkeypatch: pytest.MonkeyPatch, @@ -302,4 +345,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 236:1 0:0 0:0 +# 270:1 0:0 0:0 From d8eb1d29a4b2631626ab4e3ca2d22739aa540b4a Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 15:55:15 -0700 Subject: [PATCH 14/48] repair auto routing gates and provenance --- a0_msdmd.ts | 42 +++++- pyproject.toml | 1 + python/routes/chat.py | 7 +- python/services/agent_instance.py | 11 +- python/services/call_fn.py | 12 +- python/services/inference.py | 130 ++++++++++-------- python/services/providers/openai_provider.py | 6 +- .../tests/chec_open_comp_cont_v0.0.0alpha.py | 9 +- repl_nix_workspace.egg-info/SOURCES.txt | 2 + tests/test_a0_package.py | 12 +- tests/test_open_comp_rout_v0.0.0alpha.py | 100 +++++++++++++- 11 files changed, 253 insertions(+), 79 deletions(-) diff --git a/a0_msdmd.ts b/a0_msdmd.ts index 0d5836785..3222a5216 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,4 +1,4 @@ -// 8045:0 0:1 0:1 +// 8081:0 0:1 0:1 import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; export default defineMsdmdCollection({ @@ -1442,6 +1442,17 @@ export default defineMsdmdCollection({ "file": "python/services/heartbeat.py", "id": "a0_service_heartbeat" }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "an unpinned request is reassigned from its seed provider to a classified role-slot provider", + "since": "2026-09-09", + "then": "the effective provider is tier-gated before transport and returned in usage for billing and provenance attribution" + }, + "file": "python/services/inference.py", + "id": "inference_auto_route_gates_and_reports_effective_provider" + }, { "block": "CONTRACTS", "fields": { @@ -1453,6 +1464,17 @@ export default defineMsdmdCollection({ "file": "python/services/inference.py", "id": "inference_compatible_provider_receives_classified_role" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "an explicit concrete OpenAI model resolves through the legacy openai provider branch", + "since": "2026-09-09", + "then": "the selected model reaches the compatible transport without role-policy or environment replacement" + }, + "file": "python/services/inference.py", + "id": "inference_explicit_openai_model_is_pinned" + }, { "block": "CONTRACTS", "fields": { @@ -2823,7 +2845,7 @@ export default defineMsdmdCollection({ "call": "self::check_openai_compatible_repair_regressions", "cleanup": "none", "mutates": "none", - "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin", "requires": "python3, pytest", "timeout": "60" }, @@ -5297,6 +5319,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "cheap_provider_prefers_configured_low_cost_provider" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "inference_auto_route_gates_and_reports_effective_provider" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -5304,6 +5333,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "inference_compatible_provider_receives_classified_role" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "inference_explicit_openai_model_is_pinned" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -8045,4 +8081,4 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 8045:0 0:1 0:1 +// 8081:0 0:1 0:1 diff --git a/pyproject.toml b/pyproject.toml index d774324ac..97508eb13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ include = ["python*"] [tool.setuptools.package-data] "python.services.providers" = ["open_comp_prov_v0.0.0alpha.py"] +"python.config" = ["providers.json", "pricing.json"] [project] diff --git a/python/routes/chat.py b/python/routes/chat.py index dbc64b41a..b3c3dbe35 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 649:189 2:7 2:16 +# 652:189 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -487,6 +487,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: pin_requested_provider=bool( pending.get("pin_requested_provider", False) ), + routed_user_tier=tier, ) finally: set_approval_scope_user_id(None) @@ -604,6 +605,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: pin_requested_provider=bool( pending.get("pin_requested_provider", False) ), + routed_user_tier=tier, ) finally: set_approval_scope_user_id(None) @@ -795,6 +797,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: history, system_prompt_override=system_prompt or None, pin_requested_provider=provider_pin_requested, + enforce_routed_tier=True, ) print(f"[chat-dbg] provider={inst.provider_id!r} hist_len={len(history)} content_len={len(content or '')} content_preview={repr((content or '')[:80])}") # Use the resolved provider_id from the instance — for forge @@ -920,4 +923,4 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # then: both gate-id and scope approval replays retain that exact provider pin, including any subsequently pending gate # class: correctness # === END CONTRACTS === -# 649:189 2:7 2:16 +# 652:189 2:7 2:16 diff --git a/python/services/agent_instance.py b/python/services/agent_instance.py index 464ef55c8..0853feabf 100644 --- a/python/services/agent_instance.py +++ b/python/services/agent_instance.py @@ -1,4 +1,4 @@ -# 114:67 0:0 3:3 +# 119:67 0:0 3:3 """AgentInstance — runtime handle for "the thing that calls a model". Unifies three concepts that previously each had bespoke plumbing: @@ -96,6 +96,7 @@ async def run( skip_approval: bool = False, reasoning_effort: Optional[str] = None, pin_requested_provider: bool = True, + enforce_routed_tier: Optional[bool] = None, ) -> tuple[str, dict]: """Send history, return (content, usage). Single seam to the model. @@ -119,9 +120,13 @@ async def run( enforce_tier=self.enforce_tier, enforce_enabled=self.enforce_enabled, pin_requested_provider=pin_requested_provider, + enforce_routed_tier=enforce_routed_tier, ) # Cache the resolved provider for downstream persistence/logging. - if self.provider_id is None: + routed_provider = usage.get("provider_id") if isinstance(usage, dict) else None + if isinstance(routed_provider, str) and routed_provider: + self.provider_id = routed_provider + elif self.provider_id is None: self.provider_id, _spec = await resolve_model_id(self.model_id) return content, usage @@ -198,4 +203,4 @@ def __repr__(self) -> str: f"tools={self.use_tools}, " f"resolved={self.provider_id!r})" ) -# 114:67 0:0 3:3 +# 119:67 0:0 3:3 diff --git a/python/services/call_fn.py b/python/services/call_fn.py index b858b12fa..3ec13fc21 100644 --- a/python/services/call_fn.py +++ b/python/services/call_fn.py @@ -1,4 +1,4 @@ -# 101:86 0:0 3:2 +# 105:86 0:0 3:2 """call_fn — canonical CallFn adapter. aimmh_lib.adapters.make_call_fn pattern, ported to a0p. The CallFn is the @@ -111,6 +111,7 @@ async def call_model( enforce_tier: bool = True, enforce_enabled: bool = True, pin_requested_provider: bool = True, + enforce_routed_tier: Optional[bool] = None, ) -> tuple[str, dict]: """Module-level full-shape call. Resolves model_id → provider_id, gates on tier + provider-enabled flag, and delegates to call_provider. @@ -126,8 +127,9 @@ async def call_model( RuntimeError — provider API key missing (no silent fallback) """ provider_id, spec = await resolve_model_id(model_id) - if enforce_tier: - user_tier = await _user_tier(user_id) + gate_routed_tier = enforce_tier if enforce_routed_tier is None else enforce_routed_tier + user_tier = await _user_tier(user_id) if enforce_tier or gate_routed_tier else None + if enforce_tier and user_tier is not None: _check_tier(spec, user_tier) if enforce_enabled: if not await is_provider_enabled(provider_id): @@ -148,6 +150,8 @@ async def call_model( skip_approval=skip_approval, reasoning_effort=reasoning_effort, pin_requested_provider=pin_requested_provider, + model_override=(str(spec.get("model") or "") or None) if pin_requested_provider else None, + routed_user_tier=user_tier if gate_routed_tier else None, ) return content, usage @@ -211,4 +215,4 @@ async def _call(model_id: str, messages: list[dict], **kwargs) -> str: content, _usage = await full(model_id, messages, **kwargs) return content return _call -# 101:86 0:0 3:2 +# 105:86 0:0 3:2 diff --git a/python/services/inference.py b/python/services/inference.py index 1663ac6b1..6f8eb4d75 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,4 +1,4 @@ -# 398:129 0:0 16:15 +# 400:141 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference @@ -37,6 +37,18 @@ # then: call_provider uses that provider and its instance memory without replacing it from the shared prompt's role slot # class: correctness # since: 2026-09-09 +# +# id: inference_auto_route_gates_and_reports_effective_provider +# given: an unpinned request is reassigned from its seed provider to a classified role-slot provider +# then: the effective provider is tier-gated before transport and returned in usage for billing and provenance attribution +# class: security +# since: 2026-09-09 +# +# id: inference_explicit_openai_model_is_pinned +# given: an explicit concrete OpenAI model resolves through the legacy openai provider branch +# then: the selected model reaches the compatible transport without role-policy or environment replacement +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === import json import logging @@ -290,6 +302,13 @@ def _get_max_tool_rounds() -> int: return v if v is not None else _MAX_TOOL_ROUNDS +def _attribute_provider(result: tuple[str, dict], provider_id: str) -> tuple[str, dict]: + content, usage = result + attributed = dict(usage or {}) + attributed["provider_id"] = provider_id + return content, attributed + + async def call_provider( provider_id: str, messages: list[dict], @@ -301,7 +320,8 @@ async def call_provider( reasoning_effort: Optional[str] = None, progress_callback: Optional[Callable[[int, int], None]] = None, skip_manifest: bool = False, - pin_requested_provider: bool = False, + pin_requested_provider: bool = False, model_override: Optional[str] = None, + routed_user_tier: Optional[str] = None, ) -> tuple[str, dict]: """ Forward messages to the named provider with the system prompt prepended. @@ -315,7 +335,7 @@ async def call_provider( provider's instance memory. reasoning_effort is mapped per-provider, gated by capability flags in providers.json (single source of truth — no model slugs in code): - - OpenAI: passed via openai_router call_cfg (ignored on the openai branch) + - OpenAI: passed via openai_router call_cfg unless a concrete model is pinned - Grok: passed as reasoning_effort when spec.supports_reasoning_effort - Claude: mapped to thinking.budget_tokens when spec.supports_thinking - Gemini: honored only on the native SDK path (gemini3 spec.supports_thinking) @@ -340,10 +360,21 @@ async def call_provider( system_prompt = (system_prompt or "") + "\n\n## Instance Memory\n" + _imem if _slot_provider: provider_id = _slot_provider + if routed_user_tier is not None: + from .model_catalog import _tier_ok + min_tier = (BUILTIN_PROVIDERS.get(provider_id) or {}).get("min_tier") + if not _tier_ok(routed_user_tier, min_tier): + raise PermissionError( + f"Model requires tier {min_tier!r} or higher; caller tier is {routed_user_tier!r}" + ) messages = _build_provider_messages(messages, provider_id) if provider_id == "openai": - return await _call_openai_routed(messages, system_prompt, use_tools=use_tools, user_id=user_id, skip_approval=skip_approval) + result = await _call_openai_routed( + messages, system_prompt, use_tools=use_tools, user_id=user_id, + skip_approval=skip_approval, + model_override=model_override if pin_requested_provider else None) + return _attribute_provider(result, provider_id) spec = BUILTIN_PROVIDERS.get(provider_id) if not spec: @@ -376,12 +407,12 @@ async def call_provider( payload_messages.append({"role": "system", "content": system_prompt}) payload_messages.extend(messages) from .providers import openai_compatible_provider - return await openai_compatible_provider.call( + result = await openai_compatible_provider.call( payload_messages, provider_id=provider_id, role=_slot, - model_override=spec["model"], api_key=api_key, max_tokens=max_tokens, - use_tools=use_tools, reasoning_effort=effective_effort, - pin_model_override=pin_requested_provider, - ) + model_override=(model_override or spec["model"]), api_key=api_key, + max_tokens=max_tokens, use_tools=use_tools, + reasoning_effort=effective_effort, pin_model_override=pin_requested_provider) + return _attribute_provider(result, provider_id) api_key = os.environ.get(spec["api_key_env"], "") if not api_key: @@ -399,46 +430,39 @@ async def call_provider( if spec.get("adapter") == "openai-compatible": from .providers import openai_compatible_provider - return await openai_compatible_provider.call( + result = await openai_compatible_provider.call( payload_messages, provider_id=provider_id, role=_slot, - api_key=api_key, model_override=spec["model"], max_tokens=max_tokens, + api_key=api_key, model_override=(model_override or spec["model"]), + max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, pin_model_override=pin_requested_provider, - progress_callback=progress_callback, - ) + progress_callback=progress_callback) + return _attribute_provider(result, provider_id) if vendor == "anthropic": - return await _call_anthropic( + result = await _call_anthropic( api_key, spec["model"], payload_messages, max_tokens, - use_tools=use_tools, - reasoning_effort=reasoning_effort, - enable_caching=spec.get("supports_prompt_caching", False), - ) + use_tools=use_tools, reasoning_effort=reasoning_effort, + enable_caching=spec.get("supports_prompt_caching", False)) + return _attribute_provider(result, provider_id) if vendor == "google": from .providers.gemini_provider import call as gemini_call - return await gemini_call( - payload_messages, - api_key=api_key, - model_override=spec["model"], - max_tokens=max_tokens, - use_tools=use_tools, - reasoning_effort=reasoning_effort, - provider_id=provider_id, - supports_thinking=bool(spec.get("supports_thinking")), - ) + result = await gemini_call( + payload_messages, api_key=api_key, model_override=spec["model"], + max_tokens=max_tokens, use_tools=use_tools, + reasoning_effort=reasoning_effort, provider_id=provider_id, + supports_thinking=bool(spec.get("supports_thinking"))) + return _attribute_provider(result, provider_id) if vendor == "xai": from .providers.xai_provider import call as grok_call - return await grok_call( - payload_messages, - api_key=api_key, - model_override=spec["model"], - max_tokens=max_tokens, - use_tools=use_tools, + result = await grok_call( + payload_messages, api_key=api_key, model_override=spec["model"], + max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, - progress_callback=progress_callback, - ) + progress_callback=progress_callback) + return _attribute_provider(result, provider_id) # No-silent-fallback doctrine: if we got here the spec exists in # BUILTIN_PROVIDERS but its vendor isn't wired to a call path — raise so @@ -450,11 +474,9 @@ async def call_provider( async def _call_openai_routed( - messages: list[dict], - system_prompt: Optional[str] = None, - use_tools: bool = True, - user_id: Optional[str] = None, - skip_approval: bool = False, + messages: list[dict], system_prompt: Optional[str] = None, + use_tools: bool = True, user_id: Optional[str] = None, + skip_approval: bool = False, model_override: Optional[str] = None, ) -> tuple[str, dict]: """ Route to the appropriate role via openai_router, check approval gate, @@ -482,18 +504,19 @@ async def _call_openai_routed( route_decision = make_route_decision(task_text, pre_approved_scopes=pre_approved_scopes) role = route_decision["role"] call_cfg = make_call_config(role) + if model_override is not None: + call_cfg = {**call_cfg, "model": model_override} if route_decision["requires_approval"] and not skip_approval: import uuid gate_id = f"gate-{uuid.uuid4().hex[:8]}" packet = make_approval_packet(task_text, gate_id) - input_repr = json.dumps({"task": task_text}) output_repr = json.dumps(packet) await log_openai_event( role=role, model=call_cfg["model"], reasoning_effort=call_cfg["reasoning_effort"], - input_text=input_repr, + input_text=json.dumps({"task": task_text}), output_text=output_repr, approval_state="pending", ) @@ -538,15 +561,12 @@ async def _call_openai_routed( from .providers.openai_provider import call as openai_call content, usage = await openai_call( - full_input, - api_key=api_key, - model_override=call_cfg["model"], + full_input, api_key=api_key, model_override=call_cfg["model"], max_tokens=call_cfg["max_output_tokens"], - use_tools=use_tools, - reasoning_effort=call_cfg["reasoning_effort"], + use_tools=use_tools, reasoning_effort=call_cfg["reasoning_effort"], temperature=call_cfg["temperature"], store=call_cfg["store"], - ) + pin_model_override=model_override is not None) input_repr = json.dumps(full_input) await log_openai_event( @@ -581,14 +601,10 @@ async def _call_anthropic( """ from .providers.claude_provider import call as _claude_call return await _claude_call( - messages, - api_key=api_key, - model_override=model, - max_tokens=max_tokens, - use_tools=use_tools, + messages, api_key=api_key, model_override=model, + max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, - enable_caching=enable_caching, - ) + enable_caching=enable_caching) -# 398:129 0:0 16:15 +# 400:141 0:0 16:15 diff --git a/python/services/providers/openai_provider.py b/python/services/providers/openai_provider.py index 583f64320..f2aff12ad 100644 --- a/python/services/providers/openai_provider.py +++ b/python/services/providers/openai_provider.py @@ -1,4 +1,4 @@ -# 27:22 0:0 1:1 +# 29:22 0:0 1:1 """Compatibility wrapper for the registry-driven OpenAI provider.""" from __future__ import annotations @@ -39,6 +39,7 @@ async def call( reasoning_effort: Optional[str] = "medium", temperature: float = 1.0, store: bool = False, + pin_model_override: bool = False, ) -> tuple[str, dict]: """Run the built-in OpenAI provider through the shared transport.""" return await openai_compatible_provider.call( @@ -52,5 +53,6 @@ async def call( reasoning_effort=reasoning_effort, temperature=temperature, store=store, + pin_model_override=pin_model_override, ) -# 27:22 0:0 1:1 +# 29:22 0:0 1:1 diff --git a/python/tests/chec_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py index 1a6dc8ba3..ff03cda97 100644 --- a/python/tests/chec_open_comp_cont_v0.0.0alpha.py +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 101:19 0:0 0:0 +# 104:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -11,7 +11,7 @@ # cleanup: none # # id: check_openai_compatible_repair_regressions -# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin # call: self::check_openai_compatible_repair_regressions # requires: python3, pytest # timeout: 60 @@ -119,6 +119,9 @@ def check_openai_compatible_repair_regressions() -> None: f"{routing_tests}::test_cheap_provider_prefers_deepseek_before_expensive_fallback", f"{routing_tests}::test_approval_replays_preserve_explicit_provider_pin", f"{routing_tests}::test_call_model_leaves_auto_selected_provider_unpinned", + f"{routing_tests}::test_auto_role_route_reapplies_tier_and_reports_effective_provider", + f"{routing_tests}::test_agent_instance_caches_effective_routed_provider", + f"{routing_tests}::test_explicit_openai_model_reaches_legacy_routed_branch", f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", ] environment = dict(os.environ) @@ -141,4 +144,4 @@ def check_openai_compatible_repair_regressions() -> None: def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 101:19 0:0 0:0 +# 104:19 0:0 0:0 diff --git a/repl_nix_workspace.egg-info/SOURCES.txt b/repl_nix_workspace.egg-info/SOURCES.txt index 2d120a27c..d8e34de01 100644 --- a/repl_nix_workspace.egg-info/SOURCES.txt +++ b/repl_nix_workspace.egg-info/SOURCES.txt @@ -14,6 +14,8 @@ python/agents/platonic_regions.py python/agents/zfae.py python/config/__init__.py python/config/policy_loader.py +python/config/pricing.json +python/config/providers.json python/engine/__init__.py python/engine/memory_core.py python/engine/module_graph.py diff --git a/tests/test_a0_package.py b/tests/test_a0_package.py index cb305c23c..3180bb068 100644 --- a/tests/test_a0_package.py +++ b/tests/test_a0_package.py @@ -1,4 +1,4 @@ -# 70:10 0:0 0:0 +# 76:10 0:0 0:0 # DOC module: tests.test_a0_package # DOC label: a0 package import + CLI smoke # DOC description: Imports every module under the a0/ package to catch @@ -11,6 +11,7 @@ from pathlib import Path import subprocess import sys +import tomllib import pytest @@ -100,4 +101,11 @@ def test_instance_fanout_propagates_resolved_provider_id(): ui = (root / "client/src/components/chat-input.tsx").read_text(encoding="utf-8") assert '"provider_id": provider_id' in api assert "i.provider_id ?? VENDOR_TO_PROVIDER[i.vendor]" in ui -# 70:10 0:0 0:0 + + +def test_provider_registry_json_is_declared_as_package_data(): + root = Path(__file__).resolve().parents[1] + config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + packaged = config["tool"]["setuptools"]["package-data"]["python.config"] + assert {"providers.json", "pricing.json"}.issubset(packaged) +# 76:10 0:0 0:0 diff --git a/tests/test_open_comp_rout_v0.0.0alpha.py b/tests/test_open_comp_rout_v0.0.0alpha.py index d9c314d0b..c143ed374 100644 --- a/tests/test_open_comp_rout_v0.0.0alpha.py +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 270:1 0:0 0:0 +# 341:1 0:0 0:0 """Routing and catalog tests for registry-driven compatible providers.""" import ast @@ -112,6 +112,98 @@ async def call_provider(**kwargs): assert captured["pin_requested_provider"] is False +@pytest.mark.asyncio +async def test_auto_role_route_reapplies_tier_and_reports_effective_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import inference, openai_router + from python.services.providers import openai_compatible_provider as provider + + async def practice_slot(_slot: str): + return "practice-memory", "deepseek-pro" + + async def no_memory(_provider_id: str): + return "" + + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") + monkeypatch.setattr(inference, "_slot_routing_info", practice_slot) + monkeypatch.setattr(inference, "_instance_memory_block", no_memory) + + with pytest.raises(PermissionError, match="requires tier 'ws'"): + await inference.call_provider( + "deepseek", + [{"role": "user", "content": "practice this"}], + use_tools=False, + routed_user_tier="free", + ) + + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + + async def fake_call(messages, **kwargs): + return "role-routed", {"total_tokens": 1} + + monkeypatch.setattr(provider, "call", fake_call) + content, usage = await inference.call_provider( + "deepseek", + [{"role": "user", "content": "practice this"}], + use_tools=False, + routed_user_tier="ws", + ) + + assert content == "role-routed" + assert usage["provider_id"] == "deepseek-pro" + + +@pytest.mark.asyncio +async def test_agent_instance_caches_effective_routed_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import agent_instance + + async def fake_call_model(*args, **kwargs): + return "role-routed", {"provider_id": "deepseek-pro"} + + monkeypatch.setattr(agent_instance, "call_model", fake_call_model) + instance = agent_instance.AgentInstance(model_id="deepseek-v4-flash") + + await instance.run( + [{"role": "user", "content": "practice this"}], + pin_requested_provider=False, + ) + + assert instance.provider_id == "deepseek-pro" + + +@pytest.mark.asyncio +async def test_explicit_openai_model_reaches_legacy_routed_branch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import call_fn, inference + + captured: dict = {} + + async def no_memory(_provider_id: str): + return "" + + async def fake_openai_routed(messages, system_prompt=None, **kwargs): + captured.update(kwargs) + return "pinned-openai", {} + + monkeypatch.setattr(inference, "_instance_memory_block", no_memory) + monkeypatch.setattr(inference, "_call_openai_routed", fake_openai_routed) + content, usage = await call_fn.call_model( + "gpt-5-mini", + [{"role": "user", "content": "hi"}], + enforce_tier=False, + enforce_enabled=False, + use_tools=False, + ) + + assert content == "pinned-openai" + assert captured["model_override"] == "gpt-5-mini" + assert usage["provider_id"] == "openai" + + @pytest.mark.asyncio async def test_free_catalog_does_not_surface_cross_tier_deepseek_pro( monkeypatch: pytest.MonkeyPatch, @@ -157,12 +249,14 @@ async def fake_compatible_call(messages, **kwargs): model_override="gpt-test", reasoning_effort="high", store=True, + pin_model_override=True, ) assert (content, usage) == ("openai-ok", {}) assert captured["provider_id"] == "openai" assert captured["reasoning_effort"] == "high" assert captured["store"] is True + assert captured["pin_model_override"] is True @pytest.mark.asyncio @@ -192,7 +286,7 @@ async def fake_call(messages, **kwargs): ) assert content == "routed" - assert usage == {"total_tokens": 1} + assert usage == {"total_tokens": 1, "provider_id": "deepseek"} assert captured["provider_id"] == "deepseek" assert captured["model_override"] == "deepseek-v4-flash" assert captured["role"] == "practice" @@ -345,4 +439,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 270:1 0:0 0:0 +# 341:1 0:0 0:0 From 301959e228d1597ec7f3a46c72a6a41adbca6b93 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:29:56 -0700 Subject: [PATCH 15/48] bind routed model identity through approval --- a0_msdmd.ts | 47 +++++++++-- python/routes/chat.py | 36 ++++++++- python/services/inference.py | 76 +++++++++--------- python/services/model_catalog.py | 58 +++++++++++++- .../tests/chec_open_comp_cont_v0.0.0alpha.py | 9 ++- tests/test_open_comp_prov_v0.0.0alpha.py | 64 ++++++++++++++- tests/test_open_comp_rout_v0.0.0alpha.py | 79 +++++++++++++++++-- 7 files changed, 308 insertions(+), 61 deletions(-) diff --git a/a0_msdmd.ts b/a0_msdmd.ts index 3222a5216..a55e64dce 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,4 +1,4 @@ -// 8081:0 0:1 0:1 +// 8116:0 0:1 0:1 import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; export default defineMsdmdCollection({ @@ -851,7 +851,7 @@ export default defineMsdmdCollection({ "fields": { "class": "correctness", "given": "a provider-pinned single-model call stops at an approval gate", - "then": "both gate-id and scope approval replays retain that exact provider pin, including any subsequently pending gate" + "then": "both gate-id and scope approval replays retain that exact provider and concrete model pin, including any subsequently pending gate" }, "file": "python/routes/chat.py", "id": "chat_approval_replay_preserves_provider_pin" @@ -876,6 +876,16 @@ export default defineMsdmdCollection({ "file": "python/routes/chat.py", "id": "chat_get_other_owner_404" }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "role routing rejects the effective concrete model for the caller tier after the user message was staged", + "then": "the route removes its staged message and returns HTTP 403 instead of leaving a dangling turn or returning 500" + }, + "file": "python/routes/chat.py", + "id": "chat_routed_tier_denial_is_clean_403" + }, { "block": "CONTRACTS", "fields": { @@ -1448,7 +1458,7 @@ export default defineMsdmdCollection({ "class": "security", "given": "an unpinned request is reassigned from its seed provider to a classified role-slot provider", "since": "2026-09-09", - "then": "the effective provider is tier-gated before transport and returned in usage for billing and provenance attribution" + "then": "the role-resolved concrete model's catalog owner is tier-gated before transport and returned with that model in usage for billing and provenance attribution" }, "file": "python/services/inference.py", "id": "inference_auto_route_gates_and_reports_effective_provider" @@ -1569,6 +1579,17 @@ export default defineMsdmdCollection({ "file": "python/services/interdependent_bootstrap.py", "id": "a0_service_interdependent_bootstrap" }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "role routing resolves a concrete model that belongs to a different catalog provider than the seed provider", + "since": "2026-09-09", + "then": "entitlement and provenance use the concrete model's owning provider, while unknown routed models fail closed when a caller tier is present" + }, + "file": "python/services/model_catalog.py", + "id": "catalog_routed_model_tier_follows_concrete_owner" + }, { "block": "MODULE_BUILD", "fields": { @@ -1579,7 +1600,7 @@ export default defineMsdmdCollection({ "module_name": "model_catalog", "network_boundary": "internal", "owner": "Erin Spencer", - "public_surface": "resolve_model_id, is_provider_enabled, list_models_for_user", + "public_surface": "resolve_model_id, resolve_routed_model, routed_model_owner, is_provider_enabled, list_models_for_user", "requires": "a0_service_energy_registry", "rollback": "Revert this file; model availability resolution reverts to prior per-surface logic.", "rollout": "default_enabled", @@ -2845,7 +2866,7 @@ export default defineMsdmdCollection({ "call": "self::check_openai_compatible_repair_regressions", "cleanup": "none", "mutates": "none", - "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403", "requires": "python3, pytest", "timeout": "60" }, @@ -5305,6 +5326,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "call_fn_resolved_model_pins_provider" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "catalog_routed_model_tier_follows_concrete_owner" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -5312,6 +5340,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "chat_approval_replay_preserves_provider_pin" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "chat_routed_tier_denial_is_clean_403" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -8081,4 +8116,4 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 8081:0 0:1 0:1 +// 8116:0 0:1 0:1 diff --git a/python/routes/chat.py b/python/routes/chat.py index b3c3dbe35..9cbef621e 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 652:189 2:7 2:16 +# 677:194 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -81,6 +81,7 @@ def _attach_cost_usd(usage: dict | None, provider_id: str | None) -> None: cb.get("output", 0), cb.get("cache_read", 0), cb.get("cache_write", 0), + model=usage.get("model_id"), ) usage["cost_usd"] = round(float(cost), 6) except Exception as exc: @@ -487,6 +488,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: pin_requested_provider=bool( pending.get("pin_requested_provider", False) ), + model_override=pending.get("model_override"), routed_user_tier=tier, ) finally: @@ -605,6 +607,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: pin_requested_provider=bool( pending.get("pin_requested_provider", False) ), + model_override=pending.get("model_override"), routed_user_tier=tier, ) finally: @@ -621,6 +624,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: "pin_requested_provider": bool( pending.get("pin_requested_provider", False) ), + "model_override": pending.get("model_override"), "uid": uid, # Carry the allow-list forward so subsequent replays # continue to respect the original tool selection. @@ -851,6 +855,9 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: "system_prompt": system_prompt or None, "provider_id": provider_id, "pin_requested_provider": provider_pin_requested, + "model_override": ( + usage.get("model_id") if provider_pin_requested else None + ), "uid": uid, # Persist the allow-list so approval replay uses the same tool set. "enabled_tools": list(_conv_tools) if isinstance(_conv_tools, list) else None, @@ -893,6 +900,24 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: } except HTTPException: raise + except PermissionError as exc: + failed_user = locals().get("user_msg") + if isinstance(failed_user, dict) and failed_user.get("id") is not None: + try: + async with engine.begin() as cleanup_conn: + await cleanup_conn.execute( + _text( + "DELETE FROM messages " + "WHERE id = :message_id AND conversation_id = :conversation_id" + ), + {"message_id": failed_user["id"], "conversation_id": conv_id}, + ) + except Exception as cleanup_exc: + print(f"[chat] failed to clean rejected user message: {cleanup_exc}") + pending_entry = locals().get("pending") + if isinstance(pending_entry, dict) and conv_id not in _pending_gates: + _store_pending_gate(conv_id, pending_entry) + raise HTTPException(status_code=403, detail=str(exc)) from None except Exception as exc: tb = traceback.format_exc() print(f"[chat] send_message error: {exc}\n{tb}") @@ -920,7 +945,12 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # # id: chat_approval_replay_preserves_provider_pin # given: a provider-pinned single-model call stops at an approval gate -# then: both gate-id and scope approval replays retain that exact provider pin, including any subsequently pending gate +# then: both gate-id and scope approval replays retain that exact provider and concrete model pin, including any subsequently pending gate # class: correctness +# +# id: chat_routed_tier_denial_is_clean_403 +# given: role routing rejects the effective concrete model for the caller tier after the user message was staged +# then: the route removes its staged message and returns HTTP 403 instead of leaving a dangling turn or returning 500 +# class: security # === END CONTRACTS === -# 652:189 2:7 2:16 +# 677:194 2:7 2:16 diff --git a/python/services/inference.py b/python/services/inference.py index 6f8eb4d75..c6c619f2a 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -40,7 +40,7 @@ # # id: inference_auto_route_gates_and_reports_effective_provider # given: an unpinned request is reassigned from its seed provider to a classified role-slot provider -# then: the effective provider is tier-gated before transport and returned in usage for billing and provenance attribution +# then: the role-resolved concrete model's catalog owner is tier-gated before transport and returned with that model in usage for billing and provenance attribution # class: security # since: 2026-09-09 # @@ -302,22 +302,20 @@ def _get_max_tool_rounds() -> int: return v if v is not None else _MAX_TOOL_ROUNDS -def _attribute_provider(result: tuple[str, dict], provider_id: str) -> tuple[str, dict]: +def _attribute_provider(result: tuple[str, dict], provider_id: str, + model_id: Optional[str] = None) -> tuple[str, dict]: content, usage = result attributed = dict(usage or {}) attributed["provider_id"] = provider_id + if model_id: attributed["model_id"] = model_id return content, attributed async def call_provider( - provider_id: str, - messages: list[dict], - system_prompt: Optional[str] = None, - max_tokens: int = 8000, - use_tools: bool = True, - user_id: Optional[str] = None, - skip_approval: bool = False, - reasoning_effort: Optional[str] = None, + provider_id: str, messages: list[dict], + system_prompt: Optional[str] = None, max_tokens: int = 8000, + use_tools: bool = True, user_id: Optional[str] = None, + skip_approval: bool = False, reasoning_effort: Optional[str] = None, progress_callback: Optional[Callable[[int, int], None]] = None, skip_manifest: bool = False, pin_requested_provider: bool = False, model_override: Optional[str] = None, @@ -360,21 +358,15 @@ async def call_provider( system_prompt = (system_prompt or "") + "\n\n## Instance Memory\n" + _imem if _slot_provider: provider_id = _slot_provider - if routed_user_tier is not None: - from .model_catalog import _tier_ok - min_tier = (BUILTIN_PROVIDERS.get(provider_id) or {}).get("min_tier") - if not _tier_ok(routed_user_tier, min_tier): - raise PermissionError( - f"Model requires tier {min_tier!r} or higher; caller tier is {routed_user_tier!r}" - ) messages = _build_provider_messages(messages, provider_id) if provider_id == "openai": result = await _call_openai_routed( messages, system_prompt, use_tools=use_tools, user_id=user_id, skip_approval=skip_approval, - model_override=model_override if pin_requested_provider else None) - return _attribute_provider(result, provider_id) + model_override=model_override if pin_requested_provider else None, + routed_user_tier=routed_user_tier) + return _attribute_provider(result, result[1].get("provider_id", provider_id), result[1].get("model_id")) spec = BUILTIN_PROVIDERS.get(provider_id) if not spec: @@ -382,6 +374,10 @@ async def call_provider( f"Unknown provider_id={provider_id!r} — no spec in BUILTIN_PROVIDERS. " f"This indicates a misrouted call; fix at the caller." ) + from .model_catalog import resolve_routed_model + effective_provider_id, effective_model = await resolve_routed_model( + provider_id, _slot, pin_requested_provider=pin_requested_provider, + model_override=model_override, user_tier=routed_user_tier) # OpenAI-vendored single-model providers (openai-5.5, openai-5.5-pro and # any future siblings). The legacy "openai" provider above goes through @@ -409,10 +405,10 @@ async def call_provider( from .providers import openai_compatible_provider result = await openai_compatible_provider.call( payload_messages, provider_id=provider_id, role=_slot, - model_override=(model_override or spec["model"]), api_key=api_key, - max_tokens=max_tokens, use_tools=use_tools, - reasoning_effort=effective_effort, pin_model_override=pin_requested_provider) - return _attribute_provider(result, provider_id) + model_override=effective_model, api_key=api_key, max_tokens=max_tokens, + use_tools=use_tools, + reasoning_effort=effective_effort, pin_model_override=True) + return _attribute_provider(result, effective_provider_id, effective_model) api_key = os.environ.get(spec["api_key_env"], "") if not api_key: @@ -432,37 +428,34 @@ async def call_provider( from .providers import openai_compatible_provider result = await openai_compatible_provider.call( payload_messages, provider_id=provider_id, role=_slot, - api_key=api_key, model_override=(model_override or spec["model"]), - max_tokens=max_tokens, + api_key=api_key, model_override=effective_model, max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, - pin_model_override=pin_requested_provider, - progress_callback=progress_callback) - return _attribute_provider(result, provider_id) + pin_model_override=True, progress_callback=progress_callback) + return _attribute_provider(result, effective_provider_id, effective_model) if vendor == "anthropic": result = await _call_anthropic( - api_key, spec["model"], payload_messages, max_tokens, + api_key, effective_model, payload_messages, max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, enable_caching=spec.get("supports_prompt_caching", False)) - return _attribute_provider(result, provider_id) + return _attribute_provider(result, effective_provider_id, effective_model) if vendor == "google": from .providers.gemini_provider import call as gemini_call result = await gemini_call( - payload_messages, api_key=api_key, model_override=spec["model"], - max_tokens=max_tokens, use_tools=use_tools, - reasoning_effort=reasoning_effort, provider_id=provider_id, + payload_messages, api_key=api_key, model_override=effective_model, + max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, + provider_id=provider_id, supports_thinking=bool(spec.get("supports_thinking"))) - return _attribute_provider(result, provider_id) + return _attribute_provider(result, effective_provider_id, effective_model) if vendor == "xai": from .providers.xai_provider import call as grok_call result = await grok_call( - payload_messages, api_key=api_key, model_override=spec["model"], - max_tokens=max_tokens, use_tools=use_tools, - reasoning_effort=reasoning_effort, + payload_messages, api_key=api_key, model_override=effective_model, + max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, progress_callback=progress_callback) - return _attribute_provider(result, provider_id) + return _attribute_provider(result, effective_provider_id, effective_model) # No-silent-fallback doctrine: if we got here the spec exists in # BUILTIN_PROVIDERS but its vendor isn't wired to a call path — raise so @@ -477,6 +470,7 @@ async def _call_openai_routed( messages: list[dict], system_prompt: Optional[str] = None, use_tools: bool = True, user_id: Optional[str] = None, skip_approval: bool = False, model_override: Optional[str] = None, + routed_user_tier: Optional[str] = None, ) -> tuple[str, dict]: """ Route to the appropriate role via openai_router, check approval gate, @@ -506,6 +500,9 @@ async def _call_openai_routed( call_cfg = make_call_config(role) if model_override is not None: call_cfg = {**call_cfg, "model": model_override} + from .model_catalog import routed_model_owner + effective_provider_id = routed_model_owner( + call_cfg["model"], "openai", routed_user_tier) if route_decision["requires_approval"] and not skip_approval: import uuid @@ -525,6 +522,8 @@ async def _call_openai_routed( "gate_id": gate_id, "approval_packet": packet, "route_decision": route_decision, + "provider_id": effective_provider_id, + "model_id": call_cfg["model"], } triggered = get_triggered_actions(task_text) scope_hints: list[str] = [] @@ -567,6 +566,7 @@ async def _call_openai_routed( temperature=call_cfg["temperature"], store=call_cfg["store"], pin_model_override=model_override is not None) + usage.update({"provider_id": effective_provider_id, "model_id": call_cfg["model"]}) input_repr = json.dumps(full_input) await log_openai_event( diff --git a/python/services/model_catalog.py b/python/services/model_catalog.py index 474d2ea07..e0809416a 100644 --- a/python/services/model_catalog.py +++ b/python/services/model_catalog.py @@ -1,4 +1,4 @@ -# 109:89 0:0 7:1 +# 150:98 0:0 7:1 """model_catalog — single source of truth for "what models can this user use". Today three surfaces answer this question independently: @@ -28,7 +28,7 @@ # module_kind: service # summary: Single source of truth for "what models can this user invoke" — unifies Forge dropdown, chat chips, and subagent spawn into one tier-gated, provenance-annotated model list plus model_id resolution. # owner: Erin Spencer -# public_surface: resolve_model_id, is_provider_enabled, list_models_for_user +# public_surface: resolve_model_id, resolve_routed_model, routed_model_owner, is_provider_enabled, list_models_for_user # internal_surface: _tier_ok, _resolve_static, _user_tier # auth_boundary: none # storage_boundary: read @@ -43,6 +43,14 @@ # unresolved: none # === END MODULE_BUILD === +# === CONTRACTS === +# id: catalog_routed_model_tier_follows_concrete_owner +# given: role routing resolves a concrete model that belongs to a different catalog provider than the seed provider +# then: entitlement and provenance use the concrete model's owning provider, while unknown routed models fail closed when a caller tier is present +# class: security +# since: 2026-09-09 +# === END CONTRACTS === + from typing import Any, Optional from .energy_registry import ( @@ -84,6 +92,50 @@ def _resolve_static(model_id: str) -> Optional[tuple[str, dict]]: return None +def routed_model_owner( + model_id: str, + fallback_provider: str, + user_tier: Optional[str] = None, +) -> str: + """Return and optionally tier-gate the catalog owner of a concrete model.""" + hit = _resolve_static(model_id) + if hit is None: + if user_tier is not None: + raise PermissionError( + f"Routed model {model_id!r} has no registered tier policy" + ) + return fallback_provider + provider_id, spec = hit + min_tier = spec.get("min_tier") + if user_tier is not None and not _tier_ok(user_tier, min_tier): + raise PermissionError( + f"Model {model_id!r} requires tier {min_tier!r} or higher; " + f"caller tier is {user_tier!r}" + ) + return provider_id + + +async def resolve_routed_model( + provider_id: str, + role: str, + *, + pin_requested_provider: bool, + model_override: Optional[str], + user_tier: Optional[str], +) -> tuple[str, str]: + """Resolve one transport model, then bind it to its gated catalog owner.""" + spec = BUILTIN_PROVIDERS.get(provider_id) or {} + model_id = model_override or str(spec.get("model") or "").strip() + compatible = spec.get("adapter") == "openai-compatible" or spec.get("vendor") == "openai" + if not pin_requested_provider and compatible: + from .providers._resolver import resolve_model_for_role + model_id = await resolve_model_for_role(provider_id, role) + if not model_id: + raise ValueError(f"Provider {provider_id!r} has no routed model") + owner = routed_model_owner(model_id, provider_id, user_tier) + return owner, model_id + + async def resolve_model_id(model_id: str) -> tuple[str, dict]: """Resolve a model_id (or legacy provider_id) to (provider_id, spec). @@ -232,4 +284,4 @@ def _touch(mid: str) -> dict: }) return {"user_tier": user_tier, "providers": out_providers} -# 109:89 0:0 7:1 +# 150:98 0:0 7:1 diff --git a/python/tests/chec_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py index ff03cda97..262248b2e 100644 --- a/python/tests/chec_open_comp_cont_v0.0.0alpha.py +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 104:19 0:0 0:0 +# 107:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -11,7 +11,7 @@ # cleanup: none # # id: check_openai_compatible_repair_regressions -# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, chat_approval_replay_preserves_provider_pin +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403 # call: self::check_openai_compatible_repair_regressions # requires: python3, pytest # timeout: 60 @@ -120,8 +120,11 @@ def check_openai_compatible_repair_regressions() -> None: f"{routing_tests}::test_approval_replays_preserve_explicit_provider_pin", f"{routing_tests}::test_call_model_leaves_auto_selected_provider_unpinned", f"{routing_tests}::test_auto_role_route_reapplies_tier_and_reports_effective_provider", + f"{routing_tests}::test_role_model_override_uses_concrete_owner_tier_and_provenance", f"{routing_tests}::test_agent_instance_caches_effective_routed_provider", f"{routing_tests}::test_explicit_openai_model_reaches_legacy_routed_branch", + f"{provider_tests}::test_openai_approval_usage_retains_concrete_model", + f"{routing_tests}::test_chat_routed_tier_denial_is_a_clean_403", f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", ] environment = dict(os.environ) @@ -144,4 +147,4 @@ def check_openai_compatible_repair_regressions() -> None: def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 104:19 0:0 0:0 +# 107:19 0:0 0:0 diff --git a/tests/test_open_comp_prov_v0.0.0alpha.py b/tests/test_open_comp_prov_v0.0.0alpha.py index 9985b3be3..02db76b29 100644 --- a/tests/test_open_comp_prov_v0.0.0alpha.py +++ b/tests/test_open_comp_prov_v0.0.0alpha.py @@ -1,7 +1,8 @@ -# 325:1 0:0 0:0 +# 378:1 0:0 0:0 """Contract tests for registry-driven OpenAI-compatible providers.""" from pathlib import Path +import sys from types import SimpleNamespace import pytest @@ -25,6 +26,65 @@ def _clear_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(api_key_env, raising=False) +@pytest.mark.asyncio +async def test_openai_approval_usage_retains_concrete_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python import logger + from python.services import inference, openai_router + + async def no_seed(_items): + return None + + async def no_log(**_kwargs): + return None + + monkeypatch.setattr(logger, "seed_openai_hmmm_if_empty", no_seed) + monkeypatch.setattr(logger, "log_openai_event", no_log) + monkeypatch.setitem( + sys.modules, + "python.storage", + SimpleNamespace(storage=SimpleNamespace()), + ) + monkeypatch.setattr( + openai_router, + "make_route_decision", + lambda *_args, **_kwargs: {"role": "practice", "requires_approval": True}, + ) + monkeypatch.setattr( + openai_router, + "make_call_config", + lambda _role: { + "model": "gpt-5.5-pro", + "reasoning_effort": "high", + "max_output_tokens": 100, + "temperature": 1.0, + "store": False, + }, + ) + monkeypatch.setattr( + openai_router, + "make_approval_packet", + lambda _task, gate_id: { + "action": "write", + "impact": "test", + "rollback": "test", + "gate_id": gate_id, + }, + ) + monkeypatch.setattr(openai_router, "get_triggered_actions", lambda _task: []) + + _content, usage = await inference._call_openai_routed( + [{"role": "user", "content": "publish this"}], + model_override="gpt-5-mini", + routed_user_tier="free", + ) + + assert usage["approval_state"] == "pending" + assert usage["provider_id"] == "openai" + assert usage["model_id"] == "gpt-5-mini" + + def test_repeat_fingerprint_excludes_volatile_transport_ids() -> None: from python.services.inference import _canonical_tool_calls @@ -404,4 +464,4 @@ async def create(**request): assert "[redacted]" in content -# 325:1 0:0 0:0 +# 378:1 0:0 0:0 diff --git a/tests/test_open_comp_rout_v0.0.0alpha.py b/tests/test_open_comp_rout_v0.0.0alpha.py index c143ed374..8b348ceff 100644 --- a/tests/test_open_comp_rout_v0.0.0alpha.py +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 341:1 0:0 0:0 +# 396:1 0:0 0:0 """Routing and catalog tests for registry-driven compatible providers.""" import ast @@ -53,6 +53,10 @@ def test_approval_replays_preserve_explicit_provider_pin() -> None: any(keyword.arg == "pin_requested_provider" for keyword in call.keywords) for call in replay_calls ) + assert all( + any(keyword.arg == "model_override" for keyword in call.keywords) + for call in replay_calls + ) instance_runs = [ node @@ -73,12 +77,23 @@ def test_approval_replays_preserve_explicit_provider_pin() -> None: and isinstance(node.func, ast.Name) and node.func.id == "_store_pending_gate" ] - assert len(pending_writes) == 2 - for call in pending_writes: + declared_pending_writes = [ + call for call in pending_writes if isinstance(call.args[1], ast.Dict) + ] + assert len(declared_pending_writes) == 2 + for call in declared_pending_writes: entry = call.args[1] - assert isinstance(entry, ast.Dict) keys = {key.value for key in entry.keys if isinstance(key, ast.Constant)} assert "pin_requested_provider" in keys + assert "model_override" in keys + + +def test_chat_routed_tier_denial_is_a_clean_403() -> None: + root = Path(__file__).resolve().parents[1] + source = (root / "python/routes/chat.py").read_text(encoding="utf-8") + assert "except PermissionError as exc:" in source + assert "DELETE FROM messages " in source + assert "HTTPException(status_code=403, detail=str(exc))" in source @pytest.mark.asyncio @@ -154,6 +169,54 @@ async def fake_call(messages, **kwargs): assert usage["provider_id"] == "deepseek-pro" +@pytest.mark.asyncio +async def test_role_model_override_uses_concrete_owner_tier_and_provenance( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import inference, openai_router + from python.services.providers import openai_compatible_provider as provider + + async def no_slot(_slot: str): + return "", None + + async def no_memory(_provider_id: str): + return "" + + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "practice") + monkeypatch.setattr(inference, "_slot_routing_info", no_slot) + monkeypatch.setattr(inference, "_instance_memory_block", no_memory) + monkeypatch.setenv("DEEPSEEK_MODEL_PRACTICE", "deepseek-v4-pro") + + with pytest.raises(PermissionError, match="deepseek-v4-pro.*tier 'ws'"): + await inference.call_provider( + "deepseek", + [{"role": "user", "content": "practice this"}], + use_tools=False, + routed_user_tier="free", + ) + + captured: dict = {} + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-secret") + + async def fake_call(messages, **kwargs): + captured.update(kwargs) + return "pro-routed", {"total_tokens": 1} + + monkeypatch.setattr(provider, "call", fake_call) + content, usage = await inference.call_provider( + "deepseek", + [{"role": "user", "content": "practice this"}], + use_tools=False, + routed_user_tier="ws", + ) + + assert content == "pro-routed" + assert captured["model_override"] == "deepseek-v4-pro" + assert captured["pin_model_override"] is True + assert usage["provider_id"] == "deepseek-pro" + assert usage["model_id"] == "deepseek-v4-pro" + + @pytest.mark.asyncio async def test_agent_instance_caches_effective_routed_provider( monkeypatch: pytest.MonkeyPatch, @@ -286,7 +349,11 @@ async def fake_call(messages, **kwargs): ) assert content == "routed" - assert usage == {"total_tokens": 1, "provider_id": "deepseek"} + assert usage == { + "total_tokens": 1, + "provider_id": "deepseek", + "model_id": "deepseek-v4-flash", + } assert captured["provider_id"] == "deepseek" assert captured["model_override"] == "deepseek-v4-flash" assert captured["role"] == "practice" @@ -439,4 +506,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 341:1 0:0 0:0 +# 396:1 0:0 0:0 From 2f18a947fe02bba2f479777070353ec3ccc3b0fc Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:54:43 -0700 Subject: [PATCH 16/48] add shared compatible-provider approval gate --- python/services/approval_gate.py | 91 ++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 python/services/approval_gate.py diff --git a/python/services/approval_gate.py b/python/services/approval_gate.py new file mode 100644 index 000000000..970423ed9 --- /dev/null +++ b/python/services/approval_gate.py @@ -0,0 +1,91 @@ +# 61:22 0:0 0:0 +"""Shared approval-gate construction for every tool-capable model transport.""" +from __future__ import annotations + +# === MODULE_BUILD === +# id: a0_service_approval_gate +# module_name: approval_gate +# module_kind: service +# summary: Builds the policy route decision and pending approval response shared by legacy OpenAI and registry-driven compatible-provider transports. +# owner: Erin Spencer +# public_surface: approval_gate_result +# internal_surface: none +# auth_boundary: user approval scopes +# storage_boundary: read/write audit +# network_boundary: none +# user_data_boundary: read +# admin_only: false +# tests: tests/test_open_comp_prov_v0.0.0alpha.py +# rollout: default_enabled +# rollback: Revert compatible-provider gate wiring and restore the legacy OpenAI-local gate builder. +# requires: a0_service_openai_router +# since: 2026-09-09 +# unresolved: none +# === END MODULE_BUILD === + +import json +from typing import Optional + + +async def approval_gate_result( + messages: list[dict], *, user_id: Optional[str], skip_approval: bool, + provider_id: str, model_id: str, reasoning_effort: Optional[str], +) -> tuple[dict, Optional[tuple[str, dict]]]: + """Return the shared route decision and, when required, a pending gate.""" + from .openai_router import ( + get_triggered_actions, make_approval_packet, make_route_decision, + ) + from ..config.policy_loader import ( + get_action_scope, get_hmmm_seed_items, get_scope_categories, + ) + from ..logger import log_openai_event, seed_openai_hmmm_if_empty + + await seed_openai_hmmm_if_empty(get_hmmm_seed_items()) + task_text = " ".join( + m.get("content", "") for m in messages if m.get("role") == "user" + ) + pre_approved_scopes: set[str] = set() + if user_id: + try: + from ..storage import storage + pre_approved_scopes = await storage.get_approval_scope_names(user_id) + except Exception as scope_err: + print(f"[approval_scopes] failed to load scopes for {user_id}: {scope_err}") + route_decision = make_route_decision( + task_text, pre_approved_scopes=pre_approved_scopes + ) + if skip_approval or not route_decision["requires_approval"]: + return route_decision, None + + import uuid + gate_id = f"gate-{uuid.uuid4().hex[:8]}" + packet = make_approval_packet(task_text, gate_id) + await log_openai_event( + role=route_decision["role"], model=model_id, + reasoning_effort=reasoning_effort or "medium", + input_text=json.dumps({"task": task_text}), + output_text=json.dumps(packet), approval_state="pending", + ) + usage = { + "approval_state": "pending", "gate_id": gate_id, + "approval_packet": packet, "route_decision": route_decision, + "provider_id": provider_id, "model_id": model_id, + } + scope_categories = get_scope_categories() + scope_hints: list[str] = [] + seen_scopes: set[str] = set() + for action in get_triggered_actions(task_text): + scope = get_action_scope(action) + if scope and scope not in seen_scopes and scope in scope_categories: + label = scope_categories[scope]["label"] + scope_hints.append(f" Pre-approve all {label}: APPROVE SCOPE {scope}") + seen_scopes.add(scope) + scope_section = "\n" + "\n".join(scope_hints) if scope_hints else "" + content = ( + f"[APPROVAL REQUIRED — gate_id: {gate_id}]\n" + f"Action: {packet['action'][:120]}\nImpact: {packet['impact']}\n" + f"Rollback: {packet['rollback']}\n" + f"To approve this action: APPROVE {gate_id}{scope_section}" + ) + return route_decision, (content, usage) +# 61:22 0:0 0:0 From efe94018c9c436e2b5d1cd3b86210aab8cef1bed Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:54:55 -0700 Subject: [PATCH 17/48] bind effective compatible-provider transport identity --- python/services/inference.py | 103 +++++++++++++++-------------------- 1 file changed, 43 insertions(+), 60 deletions(-) diff --git a/python/services/inference.py b/python/services/inference.py index c6c619f2a..0ae4ddb98 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,4 +1,4 @@ -# 400:141 0:0 16:15 +# 370:156 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference @@ -15,7 +15,7 @@ # tests: hmmm # rollout: default_enabled # rollback: Revert this file; inference is the live model-call path and has no migration state. -# requires: a0_service_tool_executor, a0_service_prompt_assembly, a0_service_attachments, a0_service_energy_registry +# requires: a0_service_tool_executor, a0_service_prompt_assembly, a0_service_attachments, a0_service_energy_registry, a0_service_approval_gate # since: 2026-06-02 # unresolved: none # === END MODULE_BUILD === @@ -49,6 +49,18 @@ # then: the selected model reaches the compatible transport without role-policy or environment replacement # class: correctness # since: 2026-09-09 +# +# id: inference_compatible_provider_approval_gate +# given: a tool-enabled OpenAI-compatible provider turn requests an external write without a grant +# then: inference returns a pending approval gate before transport or tool execution and replay may bypass only with skip_approval +# class: security +# since: 2026-09-09 +# +# id: inference_compatible_transport_uses_effective_owner +# given: role routing selects a concrete model owned by a different compatible-provider catalog entry +# then: transport uses the owning provider's complete registry spec and identity as well as the selected model +# class: correctness +# since: 2026-09-09 # === END CONTRACTS === import json import logging @@ -378,6 +390,23 @@ async def call_provider( effective_provider_id, effective_model = await resolve_routed_model( provider_id, _slot, pin_requested_provider=pin_requested_provider, model_override=model_override, user_tier=routed_user_tier) + # A role-selected model can belong to a different provider entry. From + # this point onward transport must use that owner's complete configuration + # (identity, base URL, key env, capabilities), not the seed provider's. + provider_id = effective_provider_id + spec = BUILTIN_PROVIDERS[effective_provider_id] + + if use_tools and ( + spec.get("adapter") == "openai-compatible" or spec.get("vendor") == "openai" + ): + from .approval_gate import approval_gate_result + _, pending = await approval_gate_result( + messages, user_id=user_id, skip_approval=skip_approval, + provider_id=effective_provider_id, model_id=effective_model, + reasoning_effort=reasoning_effort, + ) + if pending is not None: + return pending # OpenAI-vendored single-model providers (openai-5.5, openai-5.5-pro and # any future siblings). The legacy "openai" provider above goes through @@ -479,72 +508,26 @@ async def _call_openai_routed( Call config (model, effort, etc.) is obtained separately via make_call_config(). user_id is used to load pre-approved scopes so pre-authorized actions bypass the gate. """ - from .openai_router import make_route_decision, make_call_config, make_approval_packet, get_triggered_actions - from ..logger import log_openai_event, seed_openai_hmmm_if_empty - from ..config.policy_loader import get_hmmm_seed_items, get_action_scope, get_scope_categories - from ..storage import storage - - await seed_openai_hmmm_if_empty(get_hmmm_seed_items()) + from .openai_router import make_call_config, resolve_role + from ..logger import log_openai_event task_text = " ".join(m.get("content", "") for m in messages if m.get("role") == "user") - pre_approved_scopes: set[str] = set() - if user_id: - try: - pre_approved_scopes = await storage.get_approval_scope_names(user_id) - except Exception as _scope_err: - print(f"[approval_scopes] failed to load scopes for {user_id}: {_scope_err}") - - route_decision = make_route_decision(task_text, pre_approved_scopes=pre_approved_scopes) - role = route_decision["role"] + role = resolve_role(task_text) call_cfg = make_call_config(role) if model_override is not None: call_cfg = {**call_cfg, "model": model_override} from .model_catalog import routed_model_owner effective_provider_id = routed_model_owner( call_cfg["model"], "openai", routed_user_tier) - - if route_decision["requires_approval"] and not skip_approval: - import uuid - gate_id = f"gate-{uuid.uuid4().hex[:8]}" - packet = make_approval_packet(task_text, gate_id) - output_repr = json.dumps(packet) - await log_openai_event( - role=role, - model=call_cfg["model"], - reasoning_effort=call_cfg["reasoning_effort"], - input_text=json.dumps({"task": task_text}), - output_text=output_repr, - approval_state="pending", - ) - usage = { - "approval_state": "pending", - "gate_id": gate_id, - "approval_packet": packet, - "route_decision": route_decision, - "provider_id": effective_provider_id, - "model_id": call_cfg["model"], - } - triggered = get_triggered_actions(task_text) - scope_hints: list[str] = [] - scope_categories = get_scope_categories() - seen_scopes: set[str] = set() - for action in triggered: - sc = get_action_scope(action) - if sc and sc not in seen_scopes and sc in scope_categories: - meta = scope_categories[sc] - scope_hints.append(f" Pre-approve all {meta['label']}: APPROVE SCOPE {sc}") - seen_scopes.add(sc) - scope_section = "\n" + "\n".join(scope_hints) if scope_hints else "" - content = ( - f"[APPROVAL REQUIRED — gate_id: {gate_id}]\n" - f"Action: {packet['action'][:120]}\n" - f"Impact: {packet['impact']}\n" - f"Rollback: {packet['rollback']}\n" - f"To approve this action: APPROVE {gate_id}" - f"{scope_section}" - ) - return content, usage + from .approval_gate import approval_gate_result + route_decision, pending = await approval_gate_result( + messages, user_id=user_id, skip_approval=skip_approval, + provider_id=effective_provider_id, model_id=call_cfg["model"], + reasoning_effort=call_cfg["reasoning_effort"], + ) + if pending is not None: + return pending api_key = os.environ.get("OPENAI_API_KEY", "") if not api_key: @@ -607,4 +590,4 @@ async def _call_anthropic( enable_caching=enable_caching) -# 400:141 0:0 16:15 +# 370:156 0:0 16:15 From 259a50b5a62f83455636477ceb6d47d4c5b9d7ca Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:55:07 -0700 Subject: [PATCH 18/48] align OpenAI role policy with registered models --- python/config/openai_policy.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/config/openai_policy.json b/python/config/openai_policy.json index b89845e9a..cd3f34aec 100644 --- a/python/config/openai_policy.json +++ b/python/config/openai_policy.json @@ -110,7 +110,7 @@ ] }, "practice": { - "model": "gpt-5.5-mini", + "model": "gpt-5-mini", "store": false, "parallel_tool_calls": false, "max_output_tokens": 2500, @@ -139,7 +139,7 @@ ] }, "record": { - "model": "gpt-5.5-nano", + "model": "gpt-5-nano", "store": false, "parallel_tool_calls": false, "max_output_tokens": 1200, From 153c7e25f524ad366fb3b21a226400d848ebf738 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:55:19 -0700 Subject: [PATCH 19/48] test compatible-provider approval preflight --- tests/test_open_comp_prov_v0.0.0alpha.py | 31 ++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/test_open_comp_prov_v0.0.0alpha.py b/tests/test_open_comp_prov_v0.0.0alpha.py index 02db76b29..4c878ab7b 100644 --- a/tests/test_open_comp_prov_v0.0.0alpha.py +++ b/tests/test_open_comp_prov_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 378:1 0:0 0:0 +# 400:1 0:0 0:0 """Contract tests for registry-driven OpenAI-compatible providers.""" from pathlib import Path @@ -85,6 +85,33 @@ async def no_log(**_kwargs): assert usage["model_id"] == "gpt-5-mini" +@pytest.mark.asyncio +async def test_compatible_tools_stop_at_shared_approval_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python import logger + from python.services import inference + from python.services.providers import openai_compatible_provider as provider + + async def noop(*_args, **_kwargs): + return None + + async def transport_must_not_run(*_args, **_kwargs): + raise AssertionError("transport ran before approval") + + monkeypatch.setattr(logger, "seed_openai_hmmm_if_empty", noop) + monkeypatch.setattr(logger, "log_openai_event", noop) + monkeypatch.setattr(provider, "call", transport_must_not_run) + content, usage = await inference.call_provider( + "deepseek", [{"role": "user", "content": "publish this post"}], + use_tools=True, skip_manifest=True, + ) + assert content.startswith("[APPROVAL REQUIRED") + assert usage["approval_state"] == "pending" + assert usage["provider_id"] == "deepseek" + assert usage["model_id"] == "deepseek-v4-flash" + + def test_repeat_fingerprint_excludes_volatile_transport_ids() -> None: from python.services.inference import _canonical_tool_calls @@ -464,4 +491,4 @@ async def create(**request): assert "[redacted]" in content -# 378:1 0:0 0:0 +# 400:1 0:0 0:0 From 2e4f3b43e3faeae10a597c568a251c888993ed77 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:55:31 -0700 Subject: [PATCH 20/48] test policy ownership and effective transport owner --- tests/test_open_comp_rout_v0.0.0alpha.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_open_comp_rout_v0.0.0alpha.py b/tests/test_open_comp_rout_v0.0.0alpha.py index 8b348ceff..740be41cb 100644 --- a/tests/test_open_comp_rout_v0.0.0alpha.py +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 396:1 0:0 0:0 +# 400:1 0:0 0:0 """Routing and catalog tests for registry-driven compatible providers.""" import ast @@ -211,6 +211,7 @@ async def fake_call(messages, **kwargs): ) assert content == "pro-routed" + assert captured["provider_id"] == "deepseek-pro" assert captured["model_override"] == "deepseek-v4-pro" assert captured["pin_model_override"] is True assert usage["provider_id"] == "deepseek-pro" @@ -488,10 +489,13 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( monkeypatch: pytest.MonkeyPatch, ) -> None: from python.services.energy_registry import estimate_cost, get_model_pricing - from python.services.model_catalog import resolve_model_id + from python.services.model_catalog import resolve_model_id, routed_model_owner + from python.services.openai_router import make_call_config from python.services.providers import openai_compatible_provider as provider _clear_provider_keys(monkeypatch) + assert routed_model_owner(make_call_config("practice")["model"], "openai", "free") == "openai" + assert routed_model_owner(make_call_config("record")["model"], "openai", "free") == "openai-nano" provider_id, spec = await resolve_model_id("deepseek-v4-pro") assert provider_id == "deepseek-pro" assert spec["model"] == "deepseek-v4-pro" @@ -506,4 +510,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 396:1 0:0 0:0 +# 400:1 0:0 0:0 From 86f06c75dcff6d0d6e40c4cee970191f1275b0a4 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:55:41 -0700 Subject: [PATCH 21/48] bind repair witnesses into executable contract check --- python/tests/chec_open_comp_cont_v0.0.0alpha.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/tests/chec_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py index 262248b2e..4a52a5778 100644 --- a/python/tests/chec_open_comp_cont_v0.0.0alpha.py +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 107:19 0:0 0:0 +# 109:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -11,7 +11,7 @@ # cleanup: none # # id: check_openai_compatible_repair_regressions -# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403 +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403 # call: self::check_openai_compatible_repair_regressions # requires: python3, pytest # timeout: 60 @@ -124,7 +124,9 @@ def check_openai_compatible_repair_regressions() -> None: f"{routing_tests}::test_agent_instance_caches_effective_routed_provider", f"{routing_tests}::test_explicit_openai_model_reaches_legacy_routed_branch", f"{provider_tests}::test_openai_approval_usage_retains_concrete_model", + f"{provider_tests}::test_compatible_tools_stop_at_shared_approval_gate", f"{routing_tests}::test_chat_routed_tier_denial_is_a_clean_403", + f"{routing_tests}::test_catalog_resolver_pricing_and_missing_key_are_fail_closed", f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", ] environment = dict(os.environ) @@ -147,4 +149,4 @@ def check_openai_compatible_repair_regressions() -> None: def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 107:19 0:0 0:0 +# 109:19 0:0 0:0 From 3880653ff62ec4fd0d7dc0c2def9e94fc8e7c112 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 16:55:54 -0700 Subject: [PATCH 22/48] regenerate a0 msdmd collection --- a0_msdmd.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/a0_msdmd.ts b/a0_msdmd.ts index a55e64dce..bb1451588 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,4 +1,3 @@ -// 8116:0 0:1 0:1 import { defineMsdmdCollection } from "./.agents/skills/msdmd/collection"; export default defineMsdmdCollection({ @@ -1007,6 +1006,30 @@ export default defineMsdmdCollection({ "file": "python/services/agent_lifecycle.py", "id": "a0_service_agent_lifecycle" }, + { + "block": "MODULE_BUILD", + "fields": { + "admin_only": "false", + "auth_boundary": "user approval scopes", + "internal_surface": "none", + "module_kind": "service", + "module_name": "approval_gate", + "network_boundary": "none", + "owner": "Erin Spencer", + "public_surface": "approval_gate_result", + "requires": "a0_service_openai_router", + "rollback": "Revert compatible-provider gate wiring and restore the legacy OpenAI-local gate builder.", + "rollout": "default_enabled", + "since": "2026-09-09", + "storage_boundary": "read/write audit", + "summary": "Builds the policy route decision and pending approval response shared by legacy OpenAI and registry-driven compatible-provider transports.", + "tests": "tests/test_open_comp_prov_v0.0.0alpha.py", + "unresolved": "none", + "user_data_boundary": "read" + }, + "file": "python/services/approval_gate.py", + "id": "a0_service_approval_gate" + }, { "block": "MODULE_BUILD", "fields": { @@ -1463,6 +1486,17 @@ export default defineMsdmdCollection({ "file": "python/services/inference.py", "id": "inference_auto_route_gates_and_reports_effective_provider" }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "a tool-enabled OpenAI-compatible provider turn requests an external write without a grant", + "since": "2026-09-09", + "then": "inference returns a pending approval gate before transport or tool execution and replay may bypass only with skip_approval" + }, + "file": "python/services/inference.py", + "id": "inference_compatible_provider_approval_gate" + }, { "block": "CONTRACTS", "fields": { @@ -1474,6 +1508,17 @@ export default defineMsdmdCollection({ "file": "python/services/inference.py", "id": "inference_compatible_provider_receives_classified_role" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "role routing selects a concrete model owned by a different compatible-provider catalog entry", + "since": "2026-09-09", + "then": "transport uses the owning provider's complete registry spec and identity as well as the selected model" + }, + "file": "python/services/inference.py", + "id": "inference_compatible_transport_uses_effective_owner" + }, { "block": "CONTRACTS", "fields": { @@ -1518,7 +1563,7 @@ export default defineMsdmdCollection({ "network_boundary": "external", "owner": "Erin Spencer", "public_surface": "call_provider", - "requires": "a0_service_tool_executor, a0_service_prompt_assembly, a0_service_attachments, a0_service_energy_registry", + "requires": "a0_service_tool_executor, a0_service_prompt_assembly, a0_service_attachments, a0_service_energy_registry, a0_service_approval_gate", "rollback": "Revert this file; inference is the live model-call path and has no migration state.", "rollout": "default_enabled", "since": "2026-06-02", @@ -2866,7 +2911,7 @@ export default defineMsdmdCollection({ "call": "self::check_openai_compatible_repair_regressions", "cleanup": "none", "mutates": "none", - "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403", "requires": "python3, pytest", "timeout": "60" }, @@ -5361,6 +5406,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "inference_auto_route_gates_and_reports_effective_provider" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "inference_compatible_provider_approval_gate" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -5368,6 +5420,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "inference_compatible_provider_receives_classified_role" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "inference_compatible_transport_uses_effective_owner" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -7069,6 +7128,20 @@ export default defineMsdmdCollection({ "source_id": "a0_service_agent_lifecycle", "to": "a0_platonic_ptcna_state" }, + { + "from": "a0_service_approval_gate", + "kind": "owns", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_approval_gate", + "to": "Erin Spencer" + }, + { + "from": "a0_service_approval_gate", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_approval_gate", + "to": "a0_service_openai_router" + }, { "from": "a0_service_artifacts", "kind": "owns", @@ -7265,6 +7338,13 @@ export default defineMsdmdCollection({ "source_id": "a0_service_inference", "to": "Erin Spencer" }, + { + "from": "a0_service_inference", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_inference", + "to": "a0_service_approval_gate" + }, { "from": "a0_service_inference", "kind": "requires", @@ -8116,4 +8196,3 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 8116:0 0:1 0:1 From 123cada12447b507810d585bcae2c10807066f67 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:24:46 -0700 Subject: [PATCH 23/48] add PCEA-named shared approval gate --- python/services/appr_gate_serv_v0.0.0alpha.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 python/services/appr_gate_serv_v0.0.0alpha.py diff --git a/python/services/appr_gate_serv_v0.0.0alpha.py b/python/services/appr_gate_serv_v0.0.0alpha.py new file mode 100644 index 000000000..9ebe55712 --- /dev/null +++ b/python/services/appr_gate_serv_v0.0.0alpha.py @@ -0,0 +1,108 @@ +# 75:23 0:0 0:0 +"""Shared approval-gate construction for every tool-capable model transport.""" +from __future__ import annotations + +# === MODULE_BUILD === +# id: a0_service_approval_gate +# module_name: approval_gate_service +# module_kind: service +# summary: Builds the policy route decision and pending approval response shared by legacy OpenAI and registry-driven compatible-provider transports. +# owner: Erin Spencer +# public_surface: approval_gate_result +# internal_surface: _message_text +# auth_boundary: user approval scopes +# storage_boundary: read/write audit +# network_boundary: none +# user_data_boundary: read +# admin_only: false +# tests: tests/test_appr_tool_disp_v0.0.0alpha.py +# rollout: default_enabled +# rollback: Revert compatible-provider gate wiring and restore the legacy OpenAI-local gate builder. +# requires: a0_service_openai_router +# since: 2026-09-09 +# unresolved: none +# === END MODULE_BUILD === + +import json +from typing import Any, Optional + + +def _message_text(content: Any) -> str: + """Extract only textual parts from provider-normalized message content.""" + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get("text"), str): + parts.append(item["text"]) + return "\n".join(parts) + + +async def approval_gate_result( + messages: list[dict], *, user_id: Optional[str], skip_approval: bool, + provider_id: str, model_id: str, reasoning_effort: Optional[str], +) -> tuple[dict, Optional[tuple[str, dict]]]: + """Return the shared route decision and, when required, a pending gate.""" + from .openai_router import ( + get_triggered_actions, make_approval_packet, make_route_decision, + ) + from ..config.policy_loader import ( + get_action_scope, get_hmmm_seed_items, get_scope_categories, + ) + from ..logger import log_openai_event, seed_openai_hmmm_if_empty + + await seed_openai_hmmm_if_empty(get_hmmm_seed_items()) + task_text = " ".join( + _message_text(m.get("content")) + for m in messages + if m.get("role") == "user" + ) + pre_approved_scopes: set[str] = set() + if user_id: + try: + from ..storage import storage + pre_approved_scopes = await storage.get_approval_scope_names(user_id) + except Exception as scope_err: + print(f"[approval_scopes] failed to load scopes for {user_id}: {scope_err}") + route_decision = make_route_decision( + task_text, pre_approved_scopes=pre_approved_scopes + ) + if skip_approval or not route_decision["requires_approval"]: + return route_decision, None + + import uuid + gate_id = f"gate-{uuid.uuid4().hex[:8]}" + packet = make_approval_packet(task_text, gate_id) + await log_openai_event( + role=route_decision["role"], model=model_id, + reasoning_effort=reasoning_effort or "medium", + input_text=json.dumps({"task": task_text}), + output_text=json.dumps(packet), approval_state="pending", + ) + usage = { + "approval_state": "pending", "gate_id": gate_id, + "approval_packet": packet, "route_decision": route_decision, + "provider_id": provider_id, "model_id": model_id, + } + scope_categories = get_scope_categories() + scope_hints: list[str] = [] + seen_scopes: set[str] = set() + for action in get_triggered_actions(task_text): + scope = get_action_scope(action) + if scope and scope not in seen_scopes and scope in scope_categories: + label = scope_categories[scope]["label"] + scope_hints.append(f" Pre-approve all {label}: APPROVE SCOPE {scope}") + seen_scopes.add(scope) + scope_section = "\n" + "\n".join(scope_hints) if scope_hints else "" + content = ( + f"[APPROVAL REQUIRED — gate_id: {gate_id}]\n" + f"Action: {packet['action'][:120]}\nImpact: {packet['impact']}\n" + f"Rollback: {packet['rollback']}\n" + f"To approve this action: APPROVE {gate_id}{scope_section}" + ) + return route_decision, (content, usage) +# 75:23 0:0 0:0 From 3e530c89441081971a159dc8f4b3cb3990feeafb Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:25:00 -0700 Subject: [PATCH 24/48] add approval dispatch and routed-owner witnesses --- tests/test_appr_tool_disp_v0.0.0alpha.py | 140 +++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/test_appr_tool_disp_v0.0.0alpha.py diff --git a/tests/test_appr_tool_disp_v0.0.0alpha.py b/tests/test_appr_tool_disp_v0.0.0alpha.py new file mode 100644 index 000000000..2b63bad75 --- /dev/null +++ b/tests/test_appr_tool_disp_v0.0.0alpha.py @@ -0,0 +1,140 @@ +# 112:1 0:0 0:0 +"""Focused approval-dispatch and routed-owner regression witnesses.""" +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + + +@pytest.mark.asyncio +async def test_scoped_tool_dispatch_requires_grant_or_cleared_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import tool_executor + from python.services.run_context import current_approval_gate_cleared + + calls: list[tuple[str, dict]] = [] + + async def fake_dispatch(name: str, **kwargs): + calls.append((name, kwargs)) + return "mutated" + + monkeypatch.setattr( + tool_executor, "_registry", + lambda: {"github_write_file": SimpleNamespace(approval_scope="code_self_modify")}, + ) + monkeypatch.setattr(tool_executor, "_registry_dispatch", fake_dispatch) + tool_executor.set_approval_scope_user_id(None) + blocked = await tool_executor._execute_tool_inner("github_write_file", {"path": "README.md"}) + assert "approval required" in blocked + assert calls == [] + + token = current_approval_gate_cleared.set(True) + try: + allowed = await tool_executor._execute_tool_inner( + "github_write_file", {"path": "README.md"} + ) + finally: + current_approval_gate_cleared.reset(token) + assert allowed == "mutated" + assert calls == [("github_write_file", {"path": "README.md"})] + + +@pytest.mark.asyncio +async def test_scoped_tool_dispatch_accepts_persisted_user_scope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python.services import tool_executor + + async def fake_dispatch(name: str, **_kwargs): + return name + + async def scopes(_user_id: str): + return {"code_self_modify"} + + monkeypatch.setattr( + tool_executor, "_registry", + lambda: {"github_write_file": SimpleNamespace(approval_scope="code_self_modify")}, + ) + monkeypatch.setattr(tool_executor, "_registry_dispatch", fake_dispatch) + monkeypatch.setitem( + sys.modules, "python.storage", + SimpleNamespace(storage=SimpleNamespace(get_approval_scope_names=scopes)), + ) + tool_executor.set_approval_scope_user_id("user-1") + try: + result = await tool_executor._execute_tool_inner("github_write_file", {}) + finally: + tool_executor.set_approval_scope_user_id(None) + assert result == "github_write_file" + + +@pytest.mark.asyncio +async def test_multimodal_approval_text_uses_only_text_parts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python import logger + from python.services import approval_gate_service + + async def noop(*_args, **_kwargs): + return None + + monkeypatch.setattr(logger, "seed_openai_hmmm_if_empty", noop) + route, pending = await approval_gate_service.approval_gate_result( + [{"role": "user", "content": [ + {"type": "text", "text": "publish the release note"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}, + ]}], + user_id=None, skip_approval=True, provider_id="openai-5.5", + model_id="gpt-5.5", reasoning_effort="low", + ) + assert pending is None + assert route["role"] == "perform" + + +@pytest.mark.asyncio +async def test_legacy_openai_route_transports_through_concrete_owner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python import logger + from python.services import inference, openai_router + from python.services.providers import openai_compatible_provider + + async def noop(*_args, **_kwargs): + return None + + captured: dict = {} + + async def fake_call(messages, **kwargs): + captured.update(kwargs) + return "nano", {} + + def fake_build(messages, provider_id): + captured["attachment_provider"] = provider_id + return messages + + monkeypatch.setattr(logger, "seed_openai_hmmm_if_empty", noop) + monkeypatch.setattr(logger, "log_openai_event", noop) + monkeypatch.setattr(openai_router, "resolve_role", lambda _text: "record") + monkeypatch.setattr(openai_compatible_provider, "call", fake_call) + monkeypatch.setattr(inference, "_build_provider_messages", fake_build) + monkeypatch.setenv("OPENAI_API_KEY", "test-secret") + content, usage = await inference._call_openai_routed( + [{"role": "user", "content": "classify this"}], + use_tools=False, skip_approval=True, routed_user_tier="free", + ) + assert content == "nano" + assert captured["provider_id"] == "openai-nano" + assert captured["attachment_provider"] == "openai-nano" + assert captured["model_override"] == "gpt-5-nano" + assert usage["provider_id"] == "openai-nano" + + +def test_pending_replay_pins_every_resolved_model() -> None: + source = ( + Path(__file__).resolve().parents[1] / "python/routes/chat.py" + ).read_text(encoding="utf-8") + assert '"pin_requested_provider": bool(usage.get("model_id"))' in source + assert '"model_override": usage.get("model_id")' in source +# 112:1 0:0 0:0 From d98abb846e9c03404e20b90f2b06d904a232f20c Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:25:10 -0700 Subject: [PATCH 25/48] load PCEA approval service --- python/services/__init__.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/python/services/__init__.py b/python/services/__init__.py index f959c5ce1..5dbf6dc5f 100644 --- a/python/services/__init__.py +++ b/python/services/__init__.py @@ -1,2 +1,26 @@ -# 0:0 0:0 0:0 -# 0:0 0:0 0:0 +# 19:0 0:0 0:0 +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + + +def _load_versioned_module(public_name: str, filename: str): + qualified_name = f"{__name__}.{public_name}" + existing = sys.modules.get(qualified_name) + if existing is not None: + return existing + spec = spec_from_file_location(qualified_name, Path(__file__).with_name(filename)) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {qualified_name} from {filename}") + module = module_from_spec(spec) + sys.modules[qualified_name] = module + spec.loader.exec_module(module) + return module + + +approval_gate_service = _load_versioned_module( + "approval_gate_service", "appr_gate_serv_v0.0.0alpha.py" +) + +__all__ = ["approval_gate_service"] +# 19:0 0:0 0:0 From c50b61b11ba2c9b8ba23435a80f404f668ba11ab Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:25:20 -0700 Subject: [PATCH 26/48] route legacy OpenAI through concrete owner --- python/services/inference.py | 42 +++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/python/services/inference.py b/python/services/inference.py index 0ae4ddb98..b5e7ae51b 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,4 +1,4 @@ -# 370:156 0:0 16:15 +# 379:156 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference @@ -370,8 +370,6 @@ async def call_provider( system_prompt = (system_prompt or "") + "\n\n## Instance Memory\n" + _imem if _slot_provider: provider_id = _slot_provider - messages = _build_provider_messages(messages, provider_id) - if provider_id == "openai": result = await _call_openai_routed( messages, system_prompt, use_tools=use_tools, user_id=user_id, @@ -395,12 +393,13 @@ async def call_provider( # (identity, base URL, key env, capabilities), not the seed provider's. provider_id = effective_provider_id spec = BUILTIN_PROVIDERS[effective_provider_id] + messages = _build_provider_messages(messages, effective_provider_id) if use_tools and ( spec.get("adapter") == "openai-compatible" or spec.get("vendor") == "openai" ): - from .approval_gate import approval_gate_result - _, pending = await approval_gate_result( + from . import approval_gate_service + _, pending = await approval_gate_service.approval_gate_result( messages, user_id=user_id, skip_approval=skip_approval, provider_id=effective_provider_id, model_id=effective_model, reasoning_effort=reasoning_effort, @@ -436,7 +435,8 @@ async def call_provider( payload_messages, provider_id=provider_id, role=_slot, model_override=effective_model, api_key=api_key, max_tokens=max_tokens, use_tools=use_tools, - reasoning_effort=effective_effort, pin_model_override=True) + reasoning_effort=effective_effort, pin_model_override=True, + approval_gate_cleared=skip_approval) return _attribute_provider(result, effective_provider_id, effective_model) api_key = os.environ.get(spec["api_key_env"], "") @@ -459,7 +459,8 @@ async def call_provider( payload_messages, provider_id=provider_id, role=_slot, api_key=api_key, model_override=effective_model, max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, - pin_model_override=True, progress_callback=progress_callback) + pin_model_override=True, approval_gate_cleared=skip_approval, + progress_callback=progress_callback) return _attribute_provider(result, effective_provider_id, effective_model) if vendor == "anthropic": @@ -508,10 +509,14 @@ async def _call_openai_routed( Call config (model, effort, etc.) is obtained separately via make_call_config(). user_id is used to load pre-approved scopes so pre-authorized actions bypass the gate. """ + from . import approval_gate_service from .openai_router import make_call_config, resolve_role from ..logger import log_openai_event - task_text = " ".join(m.get("content", "") for m in messages if m.get("role") == "user") + task_text = " ".join( + approval_gate_service._message_text(m.get("content")) + for m in messages if m.get("role") == "user" + ) role = resolve_role(task_text) call_cfg = make_call_config(role) @@ -520,8 +525,8 @@ async def _call_openai_routed( from .model_catalog import routed_model_owner effective_provider_id = routed_model_owner( call_cfg["model"], "openai", routed_user_tier) - from .approval_gate import approval_gate_result - route_decision, pending = await approval_gate_result( + messages = _build_provider_messages(messages, effective_provider_id) + route_decision, pending = await approval_gate_service.approval_gate_result( messages, user_id=user_id, skip_approval=skip_approval, provider_id=effective_provider_id, model_id=call_cfg["model"], reasoning_effort=call_cfg["reasoning_effort"], @@ -529,10 +534,12 @@ async def _call_openai_routed( if pending is not None: return pending - api_key = os.environ.get("OPENAI_API_KEY", "") + spec = BUILTIN_PROVIDERS[effective_provider_id] + api_key_env = str(spec.get("api_key_env") or "") + api_key = os.environ.get(api_key_env, "") if not api_key: raise RuntimeError( - "openai unavailable: env var OPENAI_API_KEY is not set. " + f"{effective_provider_id} unavailable: env var {api_key_env} is not set. " "Set the API key or route the request to a configured provider." ) @@ -541,14 +548,15 @@ async def _call_openai_routed( full_input.append({"role": "system", "content": system_prompt}) full_input.extend(messages) - from .providers.openai_provider import call as openai_call - content, usage = await openai_call( - full_input, api_key=api_key, model_override=call_cfg["model"], + from .providers import openai_compatible_provider + content, usage = await openai_compatible_provider.call( + full_input, provider_id=effective_provider_id, role=role, + api_key=api_key, model_override=call_cfg["model"], max_tokens=call_cfg["max_output_tokens"], use_tools=use_tools, reasoning_effort=call_cfg["reasoning_effort"], temperature=call_cfg["temperature"], store=call_cfg["store"], - pin_model_override=model_override is not None) + pin_model_override=True, approval_gate_cleared=skip_approval) usage.update({"provider_id": effective_provider_id, "model_id": call_cfg["model"]}) input_repr = json.dumps(full_input) @@ -590,4 +598,4 @@ async def _call_anthropic( enable_caching=enable_caching) -# 370:156 0:0 16:15 +# 379:156 0:0 16:15 From 838a4a139b654d2802b9f7ff50a644b710ab7168 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:25:31 -0700 Subject: [PATCH 27/48] scope explicit gate clearance to model call --- python/services/run_context.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python/services/run_context.py b/python/services/run_context.py index 07e7de7a4..8a34e6afd 100644 --- a/python/services/run_context.py +++ b/python/services/run_context.py @@ -1,4 +1,4 @@ -# 70:33 0:0 9:0 +# 74:33 0:0 9:0 # N:M """Run-scoped ContextVars for ZFAE recursion tracking. @@ -18,7 +18,7 @@ # module_kind: service # summary: Run-scoped ContextVars for ZFAE recursion tracking — run id, depth, root/parent run id, and approval-scope user id, inherited by async tool/inference calls and rebound on sub-agent spawn. # owner: Erin Spencer -# public_surface: get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot +# public_surface: get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, current_approval_gate_cleared, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot # internal_surface: none # auth_boundary: holds the approval-scope user id that tools read to scope pre-approved actions # storage_boundary: none @@ -60,6 +60,9 @@ current_max_tool_rounds: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar( "a0p_max_tool_rounds", default=None, ) +current_approval_gate_cleared: contextvars.ContextVar[bool] = contextvars.ContextVar( + "approval_gate_cleared", default=False, +) def get_current_run_id() -> Optional[str]: @@ -125,6 +128,7 @@ def snapshot() -> dict: "orchestration_mode": current_orchestration_mode.get(), "user_tier": current_user_tier.get(), "max_tool_rounds": current_max_tool_rounds.get(), + "approval_gate_cleared": current_approval_gate_cleared.get(), } # N:M -# 70:33 0:0 9:0 +# 74:33 0:0 9:0 From 4b893ae90cd42dfcb0f98ff75d0e6cd09efcd28a Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:25:43 -0700 Subject: [PATCH 28/48] bind approved replay context through transport --- python/services/providers/open_comp_prov_v0.0.0alpha.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/services/providers/open_comp_prov_v0.0.0alpha.py b/python/services/providers/open_comp_prov_v0.0.0alpha.py index 7719843ea..efef619ed 100644 --- a/python/services/providers/open_comp_prov_v0.0.0alpha.py +++ b/python/services/providers/open_comp_prov_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 338:55 0:0 2:5 +# 342:55 0:0 2:5 """Generic OpenAI-compatible provider transport. Provider identity, endpoint, credential name, model, API family, reasoning @@ -372,6 +372,7 @@ async def call( temperature: float = 1.0, store: bool = False, pin_model_override: bool = False, + approval_gate_cleared: bool = False, progress_callback: Optional[Callable[[int, int], None]] = None, ) -> tuple[str, dict]: """Dispatch one registry-defined provider through its configured API family.""" @@ -402,9 +403,11 @@ async def call( base_url = str(spec.get("base_url") or "").strip() or None effort = _normalize_reasoning_effort(spec, reasoning_effort) api_family = spec.get("api_family", "responses") + from ..run_context import current_approval_gate_cleared from ..tool_distill import reset_caller_provider, set_caller_provider caller_provider_token = set_caller_provider(provider_id) + approval_token = current_approval_gate_cleared.set(approval_gate_cleared) try: if api_family == "responses": tools = _response_tools(spec.get("tool_profile", "all-responses")) if use_tools else None @@ -438,5 +441,6 @@ async def call( f"Provider {provider_id!r} has unsupported api_family={api_family!r}" ) finally: + current_approval_gate_cleared.reset(approval_token) reset_caller_provider(caller_provider_token) -# 338:55 0:0 2:5 +# 342:55 0:0 2:5 From c504629d1861629f44c79cd9356295202f94ac3e Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:25:53 -0700 Subject: [PATCH 29/48] enforce approval scope at concrete tool dispatch --- python/services/tool_executor.py | 42 +++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/python/services/tool_executor.py b/python/services/tool_executor.py index dd88d44ab..a2328541f 100644 --- a/python/services/tool_executor.py +++ b/python/services/tool_executor.py @@ -1,4 +1,4 @@ -# 352:92 0:0 12:4 +# 375:100 0:0 12:4 """ZFAE Tool Executor — thin shim over the per-tool registry. Tools live in `python/services/tools/*.py` (one file per tool, self-declared @@ -22,8 +22,8 @@ # summary: Thin shim over the per-tool registry — re-exports stable TOOL_SCHEMAS lists, wraps the dispatcher with call_id persistence and distiller summarization, and owns the distiller/a0 skill loaders and approval-scope ContextVar. # owner: Erin Spencer # public_surface: set_allowed_tools, reset_allowed_tools, get_active_chat_schemas, get_active_responses_schemas, get_a0_skill_manifest, get_a0_skill_body, TOOL_SCHEMAS_CHAT, TOOL_SCHEMAS_RESPONSES, execute_tool -# internal_surface: _parse_frontmatter, _discover_distiller_specs, _pick_distiller, _get_distiller_spec, _discover_a0_skills, _score_skill_match, _skill_recommend, _skill_load -# auth_boundary: none +# internal_surface: _parse_frontmatter, _discover_distiller_specs, _pick_distiller, _get_distiller_spec, _discover_a0_skills, _score_skill_match, _skill_recommend, _skill_load, _approval_denial +# auth_boundary: enforces each registered tool approval_scope before dispatch # storage_boundary: read # network_boundary: none # user_data_boundary: read @@ -36,6 +36,14 @@ # unresolved: none # === END MODULE_BUILD === +# === CONTRACTS === +# id: tool_dispatch_enforces_approval_scope +# given: a registered tool declares an approval_scope +# then: its handler cannot run without either that user's persisted scope or an explicitly cleared per-gate replay context +# class: security +# since: 2026-09-10 +# === END CONTRACTS === + import contextvars as _cv import contextvars import os @@ -463,6 +471,29 @@ async def execute_tool(name: str, arguments: dict) -> str: return await _maybe_summarize(name, arguments or {}, raw, call_id) +async def _approval_denial(name: str) -> str | None: + """Fail closed before dispatch when a tool's declared scope is absent.""" + spec = _registry().get(name) + scope = spec.approval_scope if spec is not None else None + if not scope: + return None + from .run_context import current_approval_gate_cleared + if current_approval_gate_cleared.get(): + return None + user_id = get_approval_scope_user_id() + if user_id: + try: + from ..storage import storage + if scope in await storage.get_approval_scope_names(user_id): + return None + except Exception as exc: + print(f"[tool_approval] scope lookup failed for {name}: {exc}") + return ( + f"[approval required — tool {name!r} blocked before dispatch; " + f"required scope: {scope!r}]" + ) + + async def _execute_tool_inner(name: str, arguments: dict) -> str: """Raw dispatch — returns the tool's unfiltered output. skill_* handlers live in this module (they wrap the a0-skill loader); everything else goes @@ -476,6 +507,9 @@ async def _execute_tool_inner(name: str, arguments: dict) -> str: ) if name == "skill_load": return _skill_load(args.get("name", "")) + denial = await _approval_denial(name) + if denial is not None: + return denial try: return await _registry_dispatch(name, **args) except KeyError: @@ -504,4 +538,4 @@ async def _execute_tool_inner(name: str, arguments: dict) -> str: "get_active_chat_schemas", "get_active_responses_schemas", ] -# 352:92 0:0 12:4 +# 375:100 0:0 12:4 From 378f6cdd35328c2a9442e87a8fbbce7b1a366e0a Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:26:03 -0700 Subject: [PATCH 30/48] pin every resolved model through approval replay --- python/routes/chat.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/python/routes/chat.py b/python/routes/chat.py index 9cbef621e..3bbac6072 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 677:194 2:7 2:16 +# 675:196 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -854,10 +854,10 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: "history": history, "system_prompt": system_prompt or None, "provider_id": provider_id, - "pin_requested_provider": provider_pin_requested, - "model_override": ( - usage.get("model_id") if provider_pin_requested else None - ), + # Approval replay is a deterministic continuation of the + # already resolved turn, even when the initial choice was auto-routed. + "pin_requested_provider": bool(usage.get("model_id")), + "model_override": usage.get("model_id"), "uid": uid, # Persist the allow-list so approval replay uses the same tool set. "enabled_tools": list(_conv_tools) if isinstance(_conv_tools, list) else None, @@ -944,8 +944,8 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # class: correctness # # id: chat_approval_replay_preserves_provider_pin -# given: a provider-pinned single-model call stops at an approval gate -# then: both gate-id and scope approval replays retain that exact provider and concrete model pin, including any subsequently pending gate +# given: any explicit or auto-routed single-model call stops at an approval gate +# then: both gate-id and scope approval replays retain the already resolved provider and concrete model pin, including any subsequently pending gate # class: correctness # # id: chat_routed_tier_denial_is_clean_403 @@ -953,4 +953,4 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # then: the route removes its staged message and returns HTTP 403 instead of leaving a dangling turn or returning 500 # class: security # === END CONTRACTS === -# 677:194 2:7 2:16 +# 675:196 2:7 2:16 From 32857b0e4fc83ce696e9292997f3fc5406dba967 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:26:13 -0700 Subject: [PATCH 31/48] bind approval repair witnesses to executable check --- python/tests/chec_open_comp_cont_v0.0.0alpha.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/python/tests/chec_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py index 4a52a5778..9761cc5e2 100644 --- a/python/tests/chec_open_comp_cont_v0.0.0alpha.py +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 109:19 0:0 0:0 +# 115:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -11,7 +11,7 @@ # cleanup: none # # id: check_openai_compatible_repair_regressions -# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403 +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, tool_dispatch_enforces_approval_scope, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403 # call: self::check_openai_compatible_repair_regressions # requires: python3, pytest # timeout: 60 @@ -106,6 +106,7 @@ def check_openai_compatible_repair_regressions() -> None: provider_tests = "tests/test_open_comp_prov_v0.0.0alpha.py" routing_tests = "tests/test_open_comp_rout_v0.0.0alpha.py" adapter_tests = "tests/test_aone_open_comp_adap_v0.0.0alpha.py" + approval_tests = "tests/test_appr_tool_disp_v0.0.0alpha.py" nodes = [ f"{provider_tests}::test_repeat_fingerprint_excludes_volatile_transport_ids", f"{provider_tests}::test_stateless_openai_reasoning_requests_encrypted_state", @@ -127,6 +128,11 @@ def check_openai_compatible_repair_regressions() -> None: f"{provider_tests}::test_compatible_tools_stop_at_shared_approval_gate", f"{routing_tests}::test_chat_routed_tier_denial_is_a_clean_403", f"{routing_tests}::test_catalog_resolver_pricing_and_missing_key_are_fail_closed", + f"{approval_tests}::test_scoped_tool_dispatch_requires_grant_or_cleared_gate", + f"{approval_tests}::test_scoped_tool_dispatch_accepts_persisted_user_scope", + f"{approval_tests}::test_multimodal_approval_text_uses_only_text_parts", + f"{approval_tests}::test_legacy_openai_route_transports_through_concrete_owner", + f"{approval_tests}::test_pending_replay_pins_every_resolved_model", f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", ] environment = dict(os.environ) @@ -149,4 +155,4 @@ def check_openai_compatible_repair_regressions() -> None: def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 109:19 0:0 0:0 +# 115:19 0:0 0:0 From 99444956f5a81310d917c083f4167c995a1ea991 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:26:25 -0700 Subject: [PATCH 32/48] refresh wheel source manifest --- repl_nix_workspace.egg-info/SOURCES.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/repl_nix_workspace.egg-info/SOURCES.txt b/repl_nix_workspace.egg-info/SOURCES.txt index d8e34de01..4cd53b156 100644 --- a/repl_nix_workspace.egg-info/SOURCES.txt +++ b/repl_nix_workspace.egg-info/SOURCES.txt @@ -75,6 +75,7 @@ python/scripts/backfill_generated_images.py python/services/__init__.py python/services/agent_instance.py python/services/agent_lifecycle.py +python/services/appr_gate_serv_v0.0.0alpha.py python/services/artifacts.py python/services/attachments.py python/services/bg_tasks.py @@ -143,11 +144,11 @@ python/storage/memory.py python/storage/system.py python/storage/transcripts.py python/tests/__init__.py +python/tests/chec_open_comp_cont_v0.0.0alpha.py python/tests/contract_runner.py python/tests/test_coherence_primes.py python/tests/test_contract_runner.py python/tests/test_encoder_compiles.py -python/tests/chec_open_comp_cont_v0.0.0alpha.py python/tests/test_platonic_agent.py python/tests/test_ptcna_state.py python/tests/test_runtime_readiness.py @@ -171,6 +172,7 @@ repl_nix_workspace.egg-info/requires.txt repl_nix_workspace.egg-info/top_level.txt tests/test_a0_package.py tests/test_aone_open_comp_adap_v0.0.0alpha.py +tests/test_appr_tool_disp_v0.0.0alpha.py tests/test_artifacts.py tests/test_compute_transcript_full.py tests/test_cut_modes.py @@ -187,4 +189,4 @@ tests/test_run_context.py tests/test_skills.py tests/test_smoke.py tests/test_spawn_caps.py -tests/test_tools_registry.py +tests/test_tools_registry.py \ No newline at end of file From 67df871dc3453e30923c5702d1f1cb5739ffd8a9 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:26:33 -0700 Subject: [PATCH 33/48] remove non-PCEA approval service path --- python/services/approval_gate.py | 91 -------------------------------- 1 file changed, 91 deletions(-) delete mode 100644 python/services/approval_gate.py diff --git a/python/services/approval_gate.py b/python/services/approval_gate.py deleted file mode 100644 index 970423ed9..000000000 --- a/python/services/approval_gate.py +++ /dev/null @@ -1,91 +0,0 @@ -# 61:22 0:0 0:0 -"""Shared approval-gate construction for every tool-capable model transport.""" -from __future__ import annotations - -# === MODULE_BUILD === -# id: a0_service_approval_gate -# module_name: approval_gate -# module_kind: service -# summary: Builds the policy route decision and pending approval response shared by legacy OpenAI and registry-driven compatible-provider transports. -# owner: Erin Spencer -# public_surface: approval_gate_result -# internal_surface: none -# auth_boundary: user approval scopes -# storage_boundary: read/write audit -# network_boundary: none -# user_data_boundary: read -# admin_only: false -# tests: tests/test_open_comp_prov_v0.0.0alpha.py -# rollout: default_enabled -# rollback: Revert compatible-provider gate wiring and restore the legacy OpenAI-local gate builder. -# requires: a0_service_openai_router -# since: 2026-09-09 -# unresolved: none -# === END MODULE_BUILD === - -import json -from typing import Optional - - -async def approval_gate_result( - messages: list[dict], *, user_id: Optional[str], skip_approval: bool, - provider_id: str, model_id: str, reasoning_effort: Optional[str], -) -> tuple[dict, Optional[tuple[str, dict]]]: - """Return the shared route decision and, when required, a pending gate.""" - from .openai_router import ( - get_triggered_actions, make_approval_packet, make_route_decision, - ) - from ..config.policy_loader import ( - get_action_scope, get_hmmm_seed_items, get_scope_categories, - ) - from ..logger import log_openai_event, seed_openai_hmmm_if_empty - - await seed_openai_hmmm_if_empty(get_hmmm_seed_items()) - task_text = " ".join( - m.get("content", "") for m in messages if m.get("role") == "user" - ) - pre_approved_scopes: set[str] = set() - if user_id: - try: - from ..storage import storage - pre_approved_scopes = await storage.get_approval_scope_names(user_id) - except Exception as scope_err: - print(f"[approval_scopes] failed to load scopes for {user_id}: {scope_err}") - route_decision = make_route_decision( - task_text, pre_approved_scopes=pre_approved_scopes - ) - if skip_approval or not route_decision["requires_approval"]: - return route_decision, None - - import uuid - gate_id = f"gate-{uuid.uuid4().hex[:8]}" - packet = make_approval_packet(task_text, gate_id) - await log_openai_event( - role=route_decision["role"], model=model_id, - reasoning_effort=reasoning_effort or "medium", - input_text=json.dumps({"task": task_text}), - output_text=json.dumps(packet), approval_state="pending", - ) - usage = { - "approval_state": "pending", "gate_id": gate_id, - "approval_packet": packet, "route_decision": route_decision, - "provider_id": provider_id, "model_id": model_id, - } - scope_categories = get_scope_categories() - scope_hints: list[str] = [] - seen_scopes: set[str] = set() - for action in get_triggered_actions(task_text): - scope = get_action_scope(action) - if scope and scope not in seen_scopes and scope in scope_categories: - label = scope_categories[scope]["label"] - scope_hints.append(f" Pre-approve all {label}: APPROVE SCOPE {scope}") - seen_scopes.add(scope) - scope_section = "\n" + "\n".join(scope_hints) if scope_hints else "" - content = ( - f"[APPROVAL REQUIRED — gate_id: {gate_id}]\n" - f"Action: {packet['action'][:120]}\nImpact: {packet['impact']}\n" - f"Rollback: {packet['rollback']}\n" - f"To approve this action: APPROVE {gate_id}{scope_section}" - ) - return route_decision, (content, usage) -# 61:22 0:0 0:0 From 450706574bcdae17a11e25485fa7d82b68bb1708 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 17:26:44 -0700 Subject: [PATCH 34/48] regenerate a0 msdmd collection --- a0_msdmd.ts | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/a0_msdmd.ts b/a0_msdmd.ts index bb1451588..f7ff211a7 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -849,8 +849,8 @@ export default defineMsdmdCollection({ "block": "CONTRACTS", "fields": { "class": "correctness", - "given": "a provider-pinned single-model call stops at an approval gate", - "then": "both gate-id and scope approval replays retain that exact provider and concrete model pin, including any subsequently pending gate" + "given": "any explicit or auto-routed single-model call stops at an approval gate", + "then": "both gate-id and scope approval replays retain the already resolved provider and concrete model pin, including any subsequently pending gate" }, "file": "python/routes/chat.py", "id": "chat_approval_replay_preserves_provider_pin" @@ -1011,9 +1011,9 @@ export default defineMsdmdCollection({ "fields": { "admin_only": "false", "auth_boundary": "user approval scopes", - "internal_surface": "none", + "internal_surface": "_message_text", "module_kind": "service", - "module_name": "approval_gate", + "module_name": "approval_gate_service", "network_boundary": "none", "owner": "Erin Spencer", "public_surface": "approval_gate_result", @@ -1023,11 +1023,11 @@ export default defineMsdmdCollection({ "since": "2026-09-09", "storage_boundary": "read/write audit", "summary": "Builds the policy route decision and pending approval response shared by legacy OpenAI and registry-driven compatible-provider transports.", - "tests": "tests/test_open_comp_prov_v0.0.0alpha.py", + "tests": "tests/test_appr_tool_disp_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "read" }, - "file": "python/services/approval_gate.py", + "file": "python/services/appr_gate_serv_v0.0.0alpha.py", "id": "a0_service_approval_gate" }, { @@ -1953,7 +1953,7 @@ export default defineMsdmdCollection({ "module_name": "run_context", "network_boundary": "none", "owner": "Erin Spencer", - "public_surface": "get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot", + "public_surface": "get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, current_approval_gate_cleared, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot", "requires": "none", "rollback": "Revert this file; recursion tracking reverts to prior ContextVar surface.", "rollout": "default_enabled", @@ -2392,12 +2392,23 @@ export default defineMsdmdCollection({ "file": "python/services/tool_distill.py", "id": "a0_service_tool_distill" }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "a registered tool declares an approval_scope", + "since": "2026-09-10", + "then": "its handler cannot run without either that user's persisted scope or an explicitly cleared per-gate replay context" + }, + "file": "python/services/tool_executor.py", + "id": "tool_dispatch_enforces_approval_scope" + }, { "block": "MODULE_BUILD", "fields": { "admin_only": "false", - "auth_boundary": "none", - "internal_surface": "_parse_frontmatter, _discover_distiller_specs, _pick_distiller, _get_distiller_spec, _discover_a0_skills, _score_skill_match, _skill_recommend, _skill_load", + "auth_boundary": "enforces each registered tool approval_scope before dispatch", + "internal_surface": "_parse_frontmatter, _discover_distiller_specs, _pick_distiller, _get_distiller_spec, _discover_a0_skills, _score_skill_match, _skill_recommend, _skill_load, _approval_denial", "module_kind": "service", "module_name": "tool_executor", "network_boundary": "none", @@ -2911,7 +2922,7 @@ export default defineMsdmdCollection({ "call": "self::check_openai_compatible_repair_regressions", "cleanup": "none", "mutates": "none", - "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, tool_dispatch_enforces_approval_scope, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403", "requires": "python3, pytest", "timeout": "60" }, @@ -5469,6 +5480,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "openai_stateless_reasoning_is_replayable" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "tool_dispatch_enforces_approval_scope" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "requires", From 48f322f4e106ae44dcb90a923fc398f5ed0a751b Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:16 -0700 Subject: [PATCH 35/48] Clamp standalone reasoning effort to provider floor --- a0/adapters/open_comp_adap_v0.0.0alpha.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/a0/adapters/open_comp_adap_v0.0.0alpha.py b/a0/adapters/open_comp_adap_v0.0.0alpha.py index 3d79b1e62..d3df07346 100644 --- a/a0/adapters/open_comp_adap_v0.0.0alpha.py +++ b/a0/adapters/open_comp_adap_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 96:41 0:0 2:0 +# 100:47 0:0 2:0 """Synchronous standalone adapter for registry-defined OpenAI-compatible APIs.""" from __future__ import annotations @@ -41,6 +41,12 @@ # then: requests send store=false unless the caller explicitly opts in # class: safety # since: 2026-09-09 +# +# id: a0_openai_compatible_reasoning_floor +# given: a standalone compatible provider declares a minimum reasoning effort above the request or default +# then: the adapter raises the effective effort to that configured floor before transport +# class: correctness +# since: 2026-09-10 # === END CONTRACTS === import os @@ -58,7 +64,11 @@ def _normalize_effort(spec: dict[str, Any], requested: str | None) -> str | None mapped = (spec.get("reasoning_effort_map") or {}).get(value, value) allowed = spec.get("reasoning_efforts") or [] if allowed and mapped not in allowed: - return spec.get("default_reasoning_effort") or allowed[0] + mapped = spec.get("default_reasoning_effort") or allowed[0] + floor = spec.get("min_reasoning_effort") + order = {"minimal": 0, "low": 1, "medium": 2, "high": 3} + if floor and order.get(mapped, -1) < order.get(floor, -1): + mapped = floor return mapped @@ -152,4 +162,4 @@ def complete(self, messages: List[Message], **kwargs: Any) -> Dict[str, Any]: }, "subagents_used": [], } -# 96:41 0:0 2:0 +# 100:47 0:0 2:0 From a4254cebb956e810353fa126e873acf11fb29f42 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:17 -0700 Subject: [PATCH 36/48] Replace Boolean approval clearance with exact scopes --- python/services/run_context.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/services/run_context.py b/python/services/run_context.py index 8a34e6afd..74f83fed3 100644 --- a/python/services/run_context.py +++ b/python/services/run_context.py @@ -16,11 +16,11 @@ # id: a0_service_run_context # module_name: run_context # module_kind: service -# summary: Run-scoped ContextVars for ZFAE recursion tracking — run id, depth, root/parent run id, and approval-scope user id, inherited by async tool/inference calls and rebound on sub-agent spawn. +# summary: Run-scoped ContextVars for ZFAE recursion and exact per-gate approval scopes, inherited by async tool/inference calls and rebound on sub-agent spawn. # owner: Erin Spencer -# public_surface: get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, current_approval_gate_cleared, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot +# public_surface: get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, current_approval_gate_scopes, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot # internal_surface: none -# auth_boundary: holds the approval-scope user id that tools read to scope pre-approved actions +# auth_boundary: holds the approval-scope user id and exact per-gate tool scopes used by dispatch enforcement # storage_boundary: none # network_boundary: none # user_data_boundary: none @@ -60,8 +60,8 @@ current_max_tool_rounds: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar( "a0p_max_tool_rounds", default=None, ) -current_approval_gate_cleared: contextvars.ContextVar[bool] = contextvars.ContextVar( - "approval_gate_cleared", default=False, +current_approval_gate_scopes: contextvars.ContextVar[frozenset[str]] = contextvars.ContextVar( + "approval_gate_scopes", default=frozenset(), ) @@ -128,7 +128,7 @@ def snapshot() -> dict: "orchestration_mode": current_orchestration_mode.get(), "user_tier": current_user_tier.get(), "max_tool_rounds": current_max_tool_rounds.get(), - "approval_gate_cleared": current_approval_gate_cleared.get(), + "approval_gate_scopes": sorted(current_approval_gate_scopes.get()), } # N:M # 74:33 0:0 9:0 From 1fdda63ef5735bcc40fb9b63d1d9692ef4a16048 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:19 -0700 Subject: [PATCH 37/48] Surface exact-scope tool approval denials --- python/services/tool_executor.py | 35 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/python/services/tool_executor.py b/python/services/tool_executor.py index a2328541f..dc9d2e688 100644 --- a/python/services/tool_executor.py +++ b/python/services/tool_executor.py @@ -1,4 +1,4 @@ -# 375:100 0:0 12:4 +# 378:101 0:0 12:4 """ZFAE Tool Executor — thin shim over the per-tool registry. Tools live in `python/services/tools/*.py` (one file per tool, self-declared @@ -21,7 +21,7 @@ # module_kind: service # summary: Thin shim over the per-tool registry — re-exports stable TOOL_SCHEMAS lists, wraps the dispatcher with call_id persistence and distiller summarization, and owns the distiller/a0 skill loaders and approval-scope ContextVar. # owner: Erin Spencer -# public_surface: set_allowed_tools, reset_allowed_tools, get_active_chat_schemas, get_active_responses_schemas, get_a0_skill_manifest, get_a0_skill_body, TOOL_SCHEMAS_CHAT, TOOL_SCHEMAS_RESPONSES, execute_tool +# public_surface: set_allowed_tools, reset_allowed_tools, get_active_chat_schemas, get_active_responses_schemas, get_a0_skill_manifest, get_a0_skill_body, TOOL_SCHEMAS_CHAT, TOOL_SCHEMAS_RESPONSES, execute_tool, ToolApprovalRequired # internal_surface: _parse_frontmatter, _discover_distiller_specs, _pick_distiller, _get_distiller_spec, _discover_a0_skills, _score_skill_match, _skill_recommend, _skill_load, _approval_denial # auth_boundary: enforces each registered tool approval_scope before dispatch # storage_boundary: read @@ -39,7 +39,7 @@ # === CONTRACTS === # id: tool_dispatch_enforces_approval_scope # given: a registered tool declares an approval_scope -# then: its handler cannot run without either that user's persisted scope or an explicitly cleared per-gate replay context +# then: its handler cannot run without either that user's persisted matching scope or the same scope in an explicit per-gate replay context; a denial is surfaced for pending-gate continuation # class: security # since: 2026-09-10 # === END CONTRACTS === @@ -471,14 +471,23 @@ async def execute_tool(name: str, arguments: dict) -> str: return await _maybe_summarize(name, arguments or {}, raw, call_id) -async def _approval_denial(name: str) -> str | None: +class ToolApprovalRequired(RuntimeError): + """Signal a scoped tool denial to the transport's pending-gate boundary.""" + + def __init__(self, tool_name: str, approval_scope: str) -> None: + super().__init__(f"tool {tool_name!r} requires approval scope {approval_scope!r}") + self.tool_name = tool_name + self.approval_scope = approval_scope + + +async def _approval_denial(name: str) -> None: """Fail closed before dispatch when a tool's declared scope is absent.""" spec = _registry().get(name) scope = spec.approval_scope if spec is not None else None if not scope: return None - from .run_context import current_approval_gate_cleared - if current_approval_gate_cleared.get(): + from .run_context import current_approval_gate_scopes + if scope in current_approval_gate_scopes.get(): return None user_id = get_approval_scope_user_id() if user_id: @@ -488,10 +497,7 @@ async def _approval_denial(name: str) -> str | None: return None except Exception as exc: print(f"[tool_approval] scope lookup failed for {name}: {exc}") - return ( - f"[approval required — tool {name!r} blocked before dispatch; " - f"required scope: {scope!r}]" - ) + raise ToolApprovalRequired(name, scope) async def _execute_tool_inner(name: str, arguments: dict) -> str: @@ -507,13 +513,13 @@ async def _execute_tool_inner(name: str, arguments: dict) -> str: ) if name == "skill_load": return _skill_load(args.get("name", "")) - denial = await _approval_denial(name) - if denial is not None: - return denial + await _approval_denial(name) try: return await _registry_dispatch(name, **args) except KeyError: return f"[unknown tool: {name}]" + except ToolApprovalRequired: + raise except Exception as exc: return f"[tool error — {name}: {exc}]" @@ -524,6 +530,7 @@ async def _execute_tool_inner(name: str, arguments: dict) -> str: "TOOL_SCHEMAS_RESPONSES_ZFAE", "OPENAI_NATIVE_TOOLS", "execute_tool", + "ToolApprovalRequired", "set_caller_provider", "reset_caller_provider", "set_approval_scope_user_id", @@ -538,4 +545,4 @@ async def _execute_tool_inner(name: str, arguments: dict) -> str: "get_active_chat_schemas", "get_active_responses_schemas", ] -# 375:100 0:0 12:4 +# 378:101 0:0 12:4 From 254b99efb2c0f9bf2dd7cc979fe3ab8f766c9134 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:20 -0700 Subject: [PATCH 38/48] Bind current-turn approvals to tool scopes --- python/services/appr_gate_serv_v0.0.0alpha.py | 194 +++++++++++++----- 1 file changed, 148 insertions(+), 46 deletions(-) diff --git a/python/services/appr_gate_serv_v0.0.0alpha.py b/python/services/appr_gate_serv_v0.0.0alpha.py index 9ebe55712..638b22229 100644 --- a/python/services/appr_gate_serv_v0.0.0alpha.py +++ b/python/services/appr_gate_serv_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 75:23 0:0 0:0 +# 142:45 0:0 0:0 """Shared approval-gate construction for every tool-capable model transport.""" from __future__ import annotations @@ -6,10 +6,10 @@ # id: a0_service_approval_gate # module_name: approval_gate_service # module_kind: service -# summary: Builds the policy route decision and pending approval response shared by legacy OpenAI and registry-driven compatible-provider transports. +# summary: Builds current-turn pending approvals and converts scoped tool-dispatch denials from every transport into exact-scope replay gates. # owner: Erin Spencer -# public_surface: approval_gate_result -# internal_surface: _message_text +# public_surface: approval_gate_result, capture_tool_approval, pending_gate_entry +# internal_surface: _message_text, _current_user_text, _pending_gate, _tool_approval_gate_result # auth_boundary: user approval scopes # storage_boundary: read/write audit # network_boundary: none @@ -17,14 +17,34 @@ # admin_only: false # tests: tests/test_appr_tool_disp_v0.0.0alpha.py # rollout: default_enabled -# rollback: Revert compatible-provider gate wiring and restore the legacy OpenAI-local gate builder. -# requires: a0_service_openai_router +# rollback: Revert shared transport capture and restore the legacy OpenAI-local gate builder. +# requires: a0_service_openai_router, a0_service_run_context, a0_service_tool_executor # since: 2026-09-09 # unresolved: none # === END MODULE_BUILD === +# === CONTRACTS === +# id: approval_gate_uses_current_user_turn +# given: conversation history contains an earlier gated request and a later unrelated user turn +# then: approval classification evaluates only the latest user turn while replay retains that original turn as the latest history entry +# class: security +# since: 2026-09-10 +# +# id: approval_gate_replay_is_scope_bound +# given: a user approves one pending action and replay reaches a registered scoped tool +# then: dispatch is cleared only when the tool's declared scope exactly matches a scope recorded by that pending gate +# class: security +# since: 2026-09-10 +# +# id: approval_gate_dispatch_denial_becomes_pending +# given: any tool-capable provider selects a scoped tool that text preflight did not identify +# then: the denial becomes a pending gate carrying the concrete tool and scope instead of an inert tool-result string +# class: security +# since: 2026-09-10 +# === END CONTRACTS === + import json -from typing import Any, Optional +from typing import Any, Awaitable, Optional, Sequence def _message_text(content: Any) -> str: @@ -42,37 +62,43 @@ def _message_text(content: Any) -> str: return "\n".join(parts) -async def approval_gate_result( - messages: list[dict], *, user_id: Optional[str], skip_approval: bool, - provider_id: str, model_id: str, reasoning_effort: Optional[str], -) -> tuple[dict, Optional[tuple[str, dict]]]: - """Return the shared route decision and, when required, a pending gate.""" - from .openai_router import ( - get_triggered_actions, make_approval_packet, make_route_decision, - ) - from ..config.policy_loader import ( - get_action_scope, get_hmmm_seed_items, get_scope_categories, +def _current_user_text(messages: list[dict]) -> str: + """Return text from the latest user turn only.""" + return next( + ( + _message_text(message.get("content")) + for message in reversed(messages) + if message.get("role") == "user" + ), + "", ) - from ..logger import log_openai_event, seed_openai_hmmm_if_empty - await seed_openai_hmmm_if_empty(get_hmmm_seed_items()) - task_text = " ".join( - _message_text(m.get("content")) - for m in messages - if m.get("role") == "user" - ) - pre_approved_scopes: set[str] = set() - if user_id: - try: - from ..storage import storage - pre_approved_scopes = await storage.get_approval_scope_names(user_id) - except Exception as scope_err: - print(f"[approval_scopes] failed to load scopes for {user_id}: {scope_err}") - route_decision = make_route_decision( - task_text, pre_approved_scopes=pre_approved_scopes - ) - if skip_approval or not route_decision["requires_approval"]: - return route_decision, None + +def pending_gate_entry( + usage: dict, *, history: list[dict], system_prompt: Optional[str], + provider_id: str, uid: Optional[str], enabled_tools: Optional[list[str]], +) -> dict: + """Build the single deterministic chat continuation shape for a pending gate.""" + model_id = usage.get("model_id") + return { + "gate_id": usage.get("gate_id"), "history": history, + "system_prompt": system_prompt, + "provider_id": usage.get("provider_id") or provider_id, + "pin_requested_provider": bool(model_id), "model_override": model_id, + "approval_scopes": usage.get("approval_scopes", []), + "approval_tool": usage.get("approval_tool"), + "uid": uid, "enabled_tools": enabled_tools, + } + + +async def _pending_gate( + task_text: str, *, route_decision: dict, provider_id: str, model_id: str, + reasoning_effort: Optional[str], approval_scopes: Sequence[str], + approval_tool: Optional[str] = None, +) -> tuple[dict, tuple[str, dict]]: + from .openai_router import make_approval_packet + from ..config.policy_loader import get_scope_categories + from ..logger import log_openai_event import uuid gate_id = f"gate-{uuid.uuid4().hex[:8]}" @@ -83,20 +109,18 @@ async def approval_gate_result( input_text=json.dumps({"task": task_text}), output_text=json.dumps(packet), approval_state="pending", ) + scopes = sorted(set(approval_scopes)) usage = { "approval_state": "pending", "gate_id": gate_id, "approval_packet": packet, "route_decision": route_decision, "provider_id": provider_id, "model_id": model_id, + "approval_scopes": scopes, "approval_tool": approval_tool, } - scope_categories = get_scope_categories() - scope_hints: list[str] = [] - seen_scopes: set[str] = set() - for action in get_triggered_actions(task_text): - scope = get_action_scope(action) - if scope and scope not in seen_scopes and scope in scope_categories: - label = scope_categories[scope]["label"] - scope_hints.append(f" Pre-approve all {label}: APPROVE SCOPE {scope}") - seen_scopes.add(scope) + categories = get_scope_categories() + scope_hints = [ + f" Pre-approve all {categories[scope]['label']}: APPROVE SCOPE {scope}" + for scope in scopes if scope in categories + ] scope_section = "\n" + "\n".join(scope_hints) if scope_hints else "" content = ( f"[APPROVAL REQUIRED — gate_id: {gate_id}]\n" @@ -105,4 +129,82 @@ async def approval_gate_result( f"To approve this action: APPROVE {gate_id}{scope_section}" ) return route_decision, (content, usage) -# 75:23 0:0 0:0 + + +async def approval_gate_result( + messages: list[dict], *, user_id: Optional[str], skip_approval: bool, + provider_id: str, model_id: str, reasoning_effort: Optional[str], +) -> tuple[dict, Optional[tuple[str, dict]]]: + """Return the shared route decision and, when required, a pending gate.""" + from .openai_router import get_triggered_actions, make_route_decision + from ..config.policy_loader import ( + get_action_scope, get_hmmm_seed_items, + ) + from ..logger import seed_openai_hmmm_if_empty + + await seed_openai_hmmm_if_empty(get_hmmm_seed_items()) + task_text = _current_user_text(messages) + pre_approved_scopes: set[str] = set() + if user_id: + try: + from ..storage import storage + pre_approved_scopes = await storage.get_approval_scope_names(user_id) + except Exception as scope_err: + print(f"[approval_scopes] failed to load scopes for {user_id}: {scope_err}") + route_decision = make_route_decision( + task_text, pre_approved_scopes=pre_approved_scopes + ) + if skip_approval or not route_decision["requires_approval"]: + return route_decision, None + scopes = [ + scope for action in get_triggered_actions(task_text) + if (scope := get_action_scope(action)) + ] + return await _pending_gate( + task_text, route_decision=route_decision, provider_id=provider_id, + model_id=model_id, reasoning_effort=reasoning_effort, + approval_scopes=scopes, + ) + + +async def _tool_approval_gate_result( + messages: list[dict], *, provider_id: str, model_id: str, + reasoning_effort: Optional[str], tool_name: str, approval_scope: str, +) -> tuple[str, dict]: + from .openai_router import make_route_decision + + task_text = _current_user_text(messages) + route_decision = { + **make_route_decision(task_text), + "requires_approval": True, + } + _, pending = await _pending_gate( + task_text, route_decision=route_decision, provider_id=provider_id, + model_id=model_id, reasoning_effort=reasoning_effort, + approval_scopes=(approval_scope,), approval_tool=tool_name, + ) + return pending + + +async def capture_tool_approval( + transport: Awaitable[tuple[str, dict]], *, messages: list[dict], + provider_id: str, model_id: str, reasoning_effort: Optional[str], + approved_tool_scopes: Optional[Sequence[str]] = None, +) -> tuple[str, dict]: + """Scope one transport and turn a dispatch denial into a pending gate.""" + from .run_context import current_approval_gate_scopes + from .tool_executor import ToolApprovalRequired + + token = current_approval_gate_scopes.set(frozenset(approved_tool_scopes or ())) + try: + try: + return await transport + except ToolApprovalRequired as denied: + return await _tool_approval_gate_result( + messages, provider_id=provider_id, model_id=model_id, + reasoning_effort=reasoning_effort, + tool_name=denied.tool_name, approval_scope=denied.approval_scope, + ) + finally: + current_approval_gate_scopes.reset(token) +# 142:45 0:0 0:0 From 251be4011a415ab563cfee6aac409b8bde6c69ad Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:22 -0700 Subject: [PATCH 39/48] Gate and capture every tool-capable transport --- python/services/inference.py | 69 +++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/python/services/inference.py b/python/services/inference.py index b5e7ae51b..d286f263e 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,4 +1,4 @@ -# 379:156 0:0 16:15 +# 388:157 0:0 16:15 # === MODULE_BUILD === # id: a0_service_inference # module_name: inference @@ -51,8 +51,8 @@ # since: 2026-09-09 # # id: inference_compatible_provider_approval_gate -# given: a tool-enabled OpenAI-compatible provider turn requests an external write without a grant -# then: inference returns a pending approval gate before transport or tool execution and replay may bypass only with skip_approval +# given: a tool-enabled provider turn requests an external write without a grant, whether text preflight or actual dispatch identifies it +# then: inference returns a pending approval gate before mutation and replay bypasses text preflight only while dispatch remains limited to the gate's exact scopes # class: security # since: 2026-09-09 # @@ -65,7 +65,7 @@ import json import logging import os -from typing import Callable, Optional +from typing import Awaitable, Callable, Optional, Sequence import httpx from .prompt_assembly import _prepend_doctrine @@ -332,12 +332,14 @@ async def call_provider( skip_manifest: bool = False, pin_requested_provider: bool = False, model_override: Optional[str] = None, routed_user_tier: Optional[str] = None, + approved_tool_scopes: Optional[Sequence[str]] = None, ) -> tuple[str, dict]: """ Forward messages to the named provider with the system prompt prepended. Returns (content, usage_dict). user_id is threaded into the OpenAI path for approval-scope checking. - skip_approval=True bypasses the approval gate (used for replay after explicit APPROVE). + skip_approval=True bypasses only replayed text preflight; tool dispatch still + requires an exact match in approved_tool_scopes or a persisted user scope. skip_manifest=True omits the skill manifest from the doctrine prefix (saves ~500 tokens; use for internal/automated callers that never invoke skill_load). pin_requested_provider=True is reserved for explicit-model and multi-model @@ -375,7 +377,8 @@ async def call_provider( messages, system_prompt, use_tools=use_tools, user_id=user_id, skip_approval=skip_approval, model_override=model_override if pin_requested_provider else None, - routed_user_tier=routed_user_tier) + routed_user_tier=routed_user_tier, + approved_tool_scopes=approved_tool_scopes) return _attribute_provider(result, result[1].get("provider_id", provider_id), result[1].get("model_id")) spec = BUILTIN_PROVIDERS.get(provider_id) @@ -395,10 +398,8 @@ async def call_provider( spec = BUILTIN_PROVIDERS[effective_provider_id] messages = _build_provider_messages(messages, effective_provider_id) - if use_tools and ( - spec.get("adapter") == "openai-compatible" or spec.get("vendor") == "openai" - ): - from . import approval_gate_service + from . import approval_gate_service + if use_tools: _, pending = await approval_gate_service.approval_gate_result( messages, user_id=user_id, skip_approval=skip_approval, provider_id=effective_provider_id, model_id=effective_model, @@ -407,6 +408,13 @@ async def call_provider( if pending is not None: return pending + async def capture(transport: Awaitable[tuple[str, dict]]) -> tuple[str, dict]: + return await approval_gate_service.capture_tool_approval( + transport, messages=messages, provider_id=effective_provider_id, + model_id=effective_model, reasoning_effort=reasoning_effort, + approved_tool_scopes=approved_tool_scopes, + ) + # OpenAI-vendored single-model providers (openai-5.5, openai-5.5-pro and # any future siblings). The legacy "openai" provider above goes through # role-based router; these go through the generic compatible transport @@ -431,12 +439,11 @@ async def call_provider( payload_messages.append({"role": "system", "content": system_prompt}) payload_messages.extend(messages) from .providers import openai_compatible_provider - result = await openai_compatible_provider.call( + result = await capture(openai_compatible_provider.call( payload_messages, provider_id=provider_id, role=_slot, model_override=effective_model, api_key=api_key, max_tokens=max_tokens, use_tools=use_tools, - reasoning_effort=effective_effort, pin_model_override=True, - approval_gate_cleared=skip_approval) + reasoning_effort=effective_effort, pin_model_override=True)) return _attribute_provider(result, effective_provider_id, effective_model) api_key = os.environ.get(spec["api_key_env"], "") @@ -455,36 +462,35 @@ async def call_provider( if spec.get("adapter") == "openai-compatible": from .providers import openai_compatible_provider - result = await openai_compatible_provider.call( + result = await capture(openai_compatible_provider.call( payload_messages, provider_id=provider_id, role=_slot, api_key=api_key, model_override=effective_model, max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, - pin_model_override=True, approval_gate_cleared=skip_approval, - progress_callback=progress_callback) + pin_model_override=True, progress_callback=progress_callback)) return _attribute_provider(result, effective_provider_id, effective_model) if vendor == "anthropic": - result = await _call_anthropic( + result = await capture(_call_anthropic( api_key, effective_model, payload_messages, max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, - enable_caching=spec.get("supports_prompt_caching", False)) + enable_caching=spec.get("supports_prompt_caching", False))) return _attribute_provider(result, effective_provider_id, effective_model) if vendor == "google": from .providers.gemini_provider import call as gemini_call - result = await gemini_call( + result = await capture(gemini_call( payload_messages, api_key=api_key, model_override=effective_model, max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, provider_id=provider_id, - supports_thinking=bool(spec.get("supports_thinking"))) + supports_thinking=bool(spec.get("supports_thinking")))) return _attribute_provider(result, effective_provider_id, effective_model) if vendor == "xai": from .providers.xai_provider import call as grok_call - result = await grok_call( + result = await capture(grok_call( payload_messages, api_key=api_key, model_override=effective_model, max_tokens=max_tokens, use_tools=use_tools, reasoning_effort=reasoning_effort, - progress_callback=progress_callback) + progress_callback=progress_callback)) return _attribute_provider(result, effective_provider_id, effective_model) # No-silent-fallback doctrine: if we got here the spec exists in @@ -501,6 +507,7 @@ async def _call_openai_routed( use_tools: bool = True, user_id: Optional[str] = None, skip_approval: bool = False, model_override: Optional[str] = None, routed_user_tier: Optional[str] = None, + approved_tool_scopes: Optional[Sequence[str]] = None, ) -> tuple[str, dict]: """ Route to the appropriate role via openai_router, check approval gate, @@ -513,10 +520,7 @@ async def _call_openai_routed( from .openai_router import make_call_config, resolve_role from ..logger import log_openai_event - task_text = " ".join( - approval_gate_service._message_text(m.get("content")) - for m in messages if m.get("role") == "user" - ) + task_text = approval_gate_service._current_user_text(messages) role = resolve_role(task_text) call_cfg = make_call_config(role) @@ -549,14 +553,21 @@ async def _call_openai_routed( full_input.extend(messages) from .providers import openai_compatible_provider - content, usage = await openai_compatible_provider.call( + result = await approval_gate_service.capture_tool_approval( + openai_compatible_provider.call( full_input, provider_id=effective_provider_id, role=role, api_key=api_key, model_override=call_cfg["model"], max_tokens=call_cfg["max_output_tokens"], use_tools=use_tools, reasoning_effort=call_cfg["reasoning_effort"], temperature=call_cfg["temperature"], store=call_cfg["store"], - pin_model_override=True, approval_gate_cleared=skip_approval) + pin_model_override=True), messages=messages, + provider_id=effective_provider_id, model_id=call_cfg["model"], + reasoning_effort=call_cfg["reasoning_effort"], + approved_tool_scopes=approved_tool_scopes) + if result[1].get("approval_state") == "pending": + return result + content, usage = result usage.update({"provider_id": effective_provider_id, "model_id": call_cfg["model"]}) input_repr = json.dumps(full_input) @@ -598,4 +609,4 @@ async def _call_anthropic( enable_caching=enable_caching) -# 379:156 0:0 16:15 +# 388:157 0:0 16:15 From eef53748b6ae929a1d696f545db748da3cbe0ff1 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:23 -0700 Subject: [PATCH 40/48] Remove Boolean approval bypass and clamp effort floor --- .../services/providers/open_comp_prov_v0.0.0alpha.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/services/providers/open_comp_prov_v0.0.0alpha.py b/python/services/providers/open_comp_prov_v0.0.0alpha.py index efef619ed..6ae7128f3 100644 --- a/python/services/providers/open_comp_prov_v0.0.0alpha.py +++ b/python/services/providers/open_comp_prov_v0.0.0alpha.py @@ -88,7 +88,11 @@ def _normalize_reasoning_effort(spec: dict, effort: Optional[str]) -> Optional[s mapped = (spec.get("reasoning_effort_map") or {}).get(requested, requested) allowed = spec.get("reasoning_efforts") or [] if allowed and mapped not in allowed: - return spec.get("default_reasoning_effort") or allowed[0] + mapped = spec.get("default_reasoning_effort") or allowed[0] + floor = spec.get("min_reasoning_effort") + order = {"minimal": 0, "low": 1, "medium": 2, "high": 3} + if floor and order.get(mapped, -1) < order.get(floor, -1): + mapped = floor return mapped @@ -372,7 +376,6 @@ async def call( temperature: float = 1.0, store: bool = False, pin_model_override: bool = False, - approval_gate_cleared: bool = False, progress_callback: Optional[Callable[[int, int], None]] = None, ) -> tuple[str, dict]: """Dispatch one registry-defined provider through its configured API family.""" @@ -403,11 +406,9 @@ async def call( base_url = str(spec.get("base_url") or "").strip() or None effort = _normalize_reasoning_effort(spec, reasoning_effort) api_family = spec.get("api_family", "responses") - from ..run_context import current_approval_gate_cleared from ..tool_distill import reset_caller_provider, set_caller_provider caller_provider_token = set_caller_provider(provider_id) - approval_token = current_approval_gate_cleared.set(approval_gate_cleared) try: if api_family == "responses": tools = _response_tools(spec.get("tool_profile", "all-responses")) if use_tools else None @@ -441,6 +442,5 @@ async def call( f"Provider {provider_id!r} has unsupported api_family={api_family!r}" ) finally: - current_approval_gate_cleared.reset(approval_token) reset_caller_provider(caller_provider_token) # 342:55 0:0 2:5 From c5a92430088c1aa7f1609d858071d35b412c349e Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:25 -0700 Subject: [PATCH 41/48] Propagate scoped tool approval denials --- python/services/providers/xai_provider.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python/services/providers/xai_provider.py b/python/services/providers/xai_provider.py index d2c6dabb0..883c1c3f6 100644 --- a/python/services/providers/xai_provider.py +++ b/python/services/providers/xai_provider.py @@ -1,4 +1,4 @@ -# 251:69 0:0 1:6 +# 255:69 0:0 1:6 """xai_provider — xAI Grok via the official xai-sdk (gRPC). Migrated from raw httpx to the `xai-sdk` Python SDK (v1.12+). The contract @@ -255,7 +255,9 @@ async def _call_with_tools( ) -> tuple[str, dict]: """xai-sdk chat with our function-tool loop. Streaming when no tools.""" from ..tool_distill import set_caller_provider - from ..tool_executor import get_active_chat_schemas, execute_tool + from ..tool_executor import ( + ToolApprovalRequired, execute_tool, get_active_chat_schemas, + ) set_caller_provider("grok") client = AsyncClient(api_key=api_key) @@ -327,6 +329,8 @@ async def _call_with_tools( result = await execute_tool(name, args) chat.append(tool_result(result, tool_call_id=tc.id)) + except ToolApprovalRequired: + raise except Exception as exc: from ..inference import _sanitize_provider_error return _sanitize_provider_error("grok", exc), accumulated_usage @@ -384,4 +388,4 @@ async def _stream_chat( pass return ("".join(text_parts) or "[no content]"), accumulated_usage -# 251:69 0:0 1:6 +# 255:69 0:0 1:6 From 19bc9853c6d4d13fbe04fcd651703aa50618e96f Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:26 -0700 Subject: [PATCH 42/48] Propagate Gemini tool approval denials --- python/services/gemini_native.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python/services/gemini_native.py b/python/services/gemini_native.py index 1a1fcffa4..96957d14f 100644 --- a/python/services/gemini_native.py +++ b/python/services/gemini_native.py @@ -1,4 +1,4 @@ -# 186:66 0:0 1:1 +# 190:66 0:0 1:1 """Native google-genai SDK adapter for Gemini 2.5 and Gemini 3. Replaces the OpenAI-compat HTTP path for Gemini providers. Unlocks: @@ -41,7 +41,9 @@ from google import genai from google.genai import types as gtypes -from .tool_executor import get_active_chat_schemas, execute_tool, set_caller_provider +from .tool_executor import ( + ToolApprovalRequired, execute_tool, get_active_chat_schemas, set_caller_provider, +) _MAX_TOOL_ROUNDS = 5 @@ -274,6 +276,8 @@ async def call_gemini_native( for fc in fn_calls: try: result = await execute_tool(fc.name, dict(fc.args or {})) + except ToolApprovalRequired: + raise except Exception as exc: result = f"[tool {fc.name} error: {type(exc).__name__}]" try: @@ -289,4 +293,4 @@ async def call_gemini_native( # Loop exit safeguard (shouldn't reach here). return "[gemini: tool loop exhausted]", accumulated -# 186:66 0:0 1:1 +# 190:66 0:0 1:1 From 71a8d0118fbe876a5b6c5eb50fd5d721aae383a4 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:28 -0700 Subject: [PATCH 43/48] Persist deterministic scope-bound gate continuations --- python/routes/chat.py | 52 ++++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/python/routes/chat.py b/python/routes/chat.py index 3bbac6072..47701c41f 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 675:196 2:7 2:16 +# 672:191 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -8,6 +8,7 @@ from ..storage import storage from ..services.energy_registry import active_provider, BUILTIN_PROVIDERS, cache_breakdown, estimate_cost from ..services.inference import call_provider +from ..services import approval_gate_service from ..services.prompt_assembly import build_system_prompt from ..services.bg_tasks import spawn as _spawn_bg @@ -490,11 +491,18 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: ), model_override=pending.get("model_override"), routed_user_tier=tier, + approved_tool_scopes=pending.get("approval_scopes"), ) finally: set_approval_scope_user_id(None) _reset_at(_t_gate_at) reply = f"[APPROVED — gate {gate_id_to_approve} cleared]{scope_note}\n\n{approved_content}" + if approved_usage.get("approval_state") == "pending": + _store_pending_gate(conv_id, approval_gate_service.pending_gate_entry( + approved_usage, history=pending["history"], + system_prompt=pending["system_prompt"], provider_id=replay_provider, + uid=uid, enabled_tools=pending.get("enabled_tools"), + )) else: replay_provider = "system" reply = ( @@ -616,20 +624,12 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: replay_result = {"content": replay_content, "usage": replay_usage} reply += f"\n\nRetrying blocked action...\n\n{replay_content}" if replay_usage.get("approval_state") == "pending": - _store_pending_gate(conv_id, { - "gate_id": replay_usage.get("gate_id"), - "history": pending["history"], - "system_prompt": pending["system_prompt"], - "provider_id": pending["provider_id"], - "pin_requested_provider": bool( - pending.get("pin_requested_provider", False) - ), - "model_override": pending.get("model_override"), - "uid": uid, - # Carry the allow-list forward so subsequent replays - # continue to respect the original tool selection. - "enabled_tools": pending.get("enabled_tools"), - }) + _store_pending_gate(conv_id, approval_gate_service.pending_gate_entry( + replay_usage, history=pending["history"], + system_prompt=pending["system_prompt"], + provider_id=pending["provider_id"], uid=uid, + enabled_tools=pending.get("enabled_tools"), + )) else: replay_result = None @@ -849,19 +849,11 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: current_client_run_id.reset(_t_cri) if usage.get("approval_state") == "pending": - _store_pending_gate(conv_id, { - "gate_id": usage.get("gate_id"), - "history": history, - "system_prompt": system_prompt or None, - "provider_id": provider_id, - # Approval replay is a deterministic continuation of the - # already resolved turn, even when the initial choice was auto-routed. - "pin_requested_provider": bool(usage.get("model_id")), - "model_override": usage.get("model_id"), - "uid": uid, - # Persist the allow-list so approval replay uses the same tool set. - "enabled_tools": list(_conv_tools) if isinstance(_conv_tools, list) else None, - }) + _store_pending_gate(conv_id, approval_gate_service.pending_gate_entry( + usage, history=history, system_prompt=system_prompt or None, + provider_id=provider_id, uid=uid, + enabled_tools=list(_conv_tools) if isinstance(_conv_tools, list) else None, + )) _attach_cost_usd(usage, provider_id) @@ -945,7 +937,7 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # # id: chat_approval_replay_preserves_provider_pin # given: any explicit or auto-routed single-model call stops at an approval gate -# then: both gate-id and scope approval replays retain the already resolved provider and concrete model pin, including any subsequently pending gate +# then: both replay paths retain the resolved provider/model, gate-id replay carries only the exact approved tool scopes, and any subsequent denial replaces the pending gate # class: correctness # # id: chat_routed_tier_denial_is_clean_403 @@ -953,4 +945,4 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # then: the route removes its staged message and returns HTTP 403 instead of leaving a dangling turn or returning 500 # class: security # === END CONTRACTS === -# 675:196 2:7 2:16 +# 672:191 2:7 2:16 From 36a0746552dbfbf84c63f98b76d9a496b393d54f Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:29 -0700 Subject: [PATCH 44/48] Test standalone reasoning floor --- tests/test_aone_open_comp_adap_v0.0.0alpha.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_aone_open_comp_adap_v0.0.0alpha.py b/tests/test_aone_open_comp_adap_v0.0.0alpha.py index d786fae98..1b781b467 100644 --- a/tests/test_aone_open_comp_adap_v0.0.0alpha.py +++ b/tests/test_aone_open_comp_adap_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 116:1 0:0 0:0 +# 121:1 0:0 0:0 """Standalone a0 selection and OpenAI-compatible adapter contracts.""" from types import SimpleNamespace @@ -121,6 +121,12 @@ def value_error_with_secret(**request): ) assert captured["request"]["store"] is True + monkeypatch.setenv("A0_PROVIDER", "openai-5.5-pro") + provider_id, spec = resolve_openai_compatible_provider() + pro_adapter = adapter_module.OpenAICompatibleAdapter(provider_id, spec) + pro_adapter.complete([{"role": "user", "content": "use the configured floor"}]) + assert captured["request"]["reasoning"] == {"effort": "high"} + def test_router_prefers_configured_generic_adapter( monkeypatch: pytest.MonkeyPatch, @@ -153,4 +159,4 @@ def test_explicit_provider_missing_key_fails_closed( with pytest.raises(ValueError, match="DEEPSEEK_API_KEY not configured"): _select_adapter(request) -# 116:1 0:0 0:0 +# 121:1 0:0 0:0 From 7f8ce52d8a8347387cb0e3af9987c6bb3dec9033 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:31 -0700 Subject: [PATCH 45/48] Test current-turn and scope-bound approvals --- tests/test_appr_tool_disp_v0.0.0alpha.py | 106 ++++++++++++++++++++--- 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/tests/test_appr_tool_disp_v0.0.0alpha.py b/tests/test_appr_tool_disp_v0.0.0alpha.py index 2b63bad75..24de9ef34 100644 --- a/tests/test_appr_tool_disp_v0.0.0alpha.py +++ b/tests/test_appr_tool_disp_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 112:1 0:0 0:0 +# 185:1 0:0 0:0 """Focused approval-dispatch and routed-owner regression witnesses.""" from pathlib import Path import sys @@ -12,7 +12,8 @@ async def test_scoped_tool_dispatch_requires_grant_or_cleared_gate( monkeypatch: pytest.MonkeyPatch, ) -> None: from python.services import tool_executor - from python.services.run_context import current_approval_gate_cleared + from python.services.run_context import current_approval_gate_scopes + from python.services.tool_executor import ToolApprovalRequired calls: list[tuple[str, dict]] = [] @@ -22,23 +23,37 @@ async def fake_dispatch(name: str, **kwargs): monkeypatch.setattr( tool_executor, "_registry", - lambda: {"github_write_file": SimpleNamespace(approval_scope="code_self_modify")}, + lambda: { + "github_write_file": SimpleNamespace(approval_scope="code_self_modify"), + "post_tweet": SimpleNamespace(approval_scope="publish"), + }, ) monkeypatch.setattr(tool_executor, "_registry_dispatch", fake_dispatch) tool_executor.set_approval_scope_user_id(None) - blocked = await tool_executor._execute_tool_inner("github_write_file", {"path": "README.md"}) - assert "approval required" in blocked + with pytest.raises(ToolApprovalRequired, match="code_self_modify"): + await tool_executor._execute_tool_inner("github_write_file", {"path": "README.md"}) assert calls == [] - token = current_approval_gate_cleared.set(True) + token = current_approval_gate_scopes.set(frozenset({"publish"})) + try: + with pytest.raises(ToolApprovalRequired, match="code_self_modify"): + await tool_executor._execute_tool_inner("github_write_file", {}) + assert await tool_executor._execute_tool_inner("post_tweet", {}) == "mutated" + finally: + current_approval_gate_scopes.reset(token) + + token = current_approval_gate_scopes.set(frozenset({"code_self_modify"})) try: allowed = await tool_executor._execute_tool_inner( "github_write_file", {"path": "README.md"} ) finally: - current_approval_gate_cleared.reset(token) + current_approval_gate_scopes.reset(token) assert allowed == "mutated" - assert calls == [("github_write_file", {"path": "README.md"})] + assert calls == [ + ("post_tweet", {}), + ("github_write_file", {"path": "README.md"}), + ] @pytest.mark.asyncio @@ -93,6 +108,60 @@ async def noop(*_args, **_kwargs): assert route["role"] == "perform" +@pytest.mark.asyncio +async def test_approval_gate_uses_only_latest_user_turn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python import logger + from python.services import approval_gate_service + + async def noop(*_args, **_kwargs): + return None + + monkeypatch.setattr(logger, "seed_openai_hmmm_if_empty", noop) + route, pending = await approval_gate_service.approval_gate_result( + [ + {"role": "user", "content": "publish the release"}, + {"role": "assistant", "content": "done"}, + {"role": "user", "content": "summarize the result"}, + ], + user_id=None, skip_approval=False, provider_id="claude", + model_id="claude-test", reasoning_effort=None, + ) + assert route["requires_approval"] is False + assert pending is None + + +@pytest.mark.asyncio +async def test_dispatch_denial_becomes_scope_bound_pending_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from python import logger + from python.services import approval_gate_service + from python.services.run_context import current_approval_gate_scopes + from python.services.tool_executor import ToolApprovalRequired + + async def noop(*_args, **_kwargs): + return None + + async def denied_transport(): + assert current_approval_gate_scopes.get() == frozenset({"publish"}) + raise ToolApprovalRequired("github_write_file", "code_self_modify") + + monkeypatch.setattr(logger, "log_openai_event", noop) + content, usage = await approval_gate_service.capture_tool_approval( + denied_transport(), + messages=[{"role": "user", "content": "Update README."}], + provider_id="claude", model_id="claude-test", reasoning_effort=None, + approved_tool_scopes=("publish",), + ) + assert "APPROVAL REQUIRED" in content + assert usage["approval_state"] == "pending" + assert usage["approval_scopes"] == ["code_self_modify"] + assert usage["approval_tool"] == "github_write_file" + assert current_approval_gate_scopes.get() == frozenset() + + @pytest.mark.asyncio async def test_legacy_openai_route_transports_through_concrete_owner( monkeypatch: pytest.MonkeyPatch, @@ -132,9 +201,24 @@ def fake_build(messages, provider_id): def test_pending_replay_pins_every_resolved_model() -> None: + from python.services import approval_gate_service + source = ( Path(__file__).resolve().parents[1] / "python/routes/chat.py" ).read_text(encoding="utf-8") - assert '"pin_requested_provider": bool(usage.get("model_id"))' in source - assert '"model_override": usage.get("model_id")' in source -# 112:1 0:0 0:0 + assert "approval_gate_service.pending_gate_entry" in source + assert 'approved_tool_scopes=pending.get("approval_scopes")' in source + entry = approval_gate_service.pending_gate_entry( + { + "gate_id": "gate-test", "provider_id": "deepseek", + "model_id": "deepseek-v4-pro", "approval_scopes": ["publish"], + "approval_tool": "post_tweet", + }, + history=[], system_prompt=None, provider_id="seed", uid="user-1", + enabled_tools=["post_tweet"], + ) + assert entry["pin_requested_provider"] is True + assert entry["model_override"] == "deepseek-v4-pro" + assert entry["approval_scopes"] == ["publish"] + assert entry["approval_tool"] == "post_tweet" +# 185:1 0:0 0:0 From ff2ab4f3f74588b4b79a0906ba97bd17dd41ee0e Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:32 -0700 Subject: [PATCH 46/48] Verify centralized pending gate continuations --- tests/test_open_comp_rout_v0.0.0alpha.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/test_open_comp_rout_v0.0.0alpha.py b/tests/test_open_comp_rout_v0.0.0alpha.py index 740be41cb..6c74d146a 100644 --- a/tests/test_open_comp_rout_v0.0.0alpha.py +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 400:1 0:0 0:0 +# 398:1 0:0 0:0 """Routing and catalog tests for registry-driven compatible providers.""" import ast @@ -78,14 +78,12 @@ def test_approval_replays_preserve_explicit_provider_pin() -> None: and node.func.id == "_store_pending_gate" ] declared_pending_writes = [ - call for call in pending_writes if isinstance(call.args[1], ast.Dict) + call for call in pending_writes + if isinstance(call.args[1], ast.Call) + and isinstance(call.args[1].func, ast.Attribute) + and call.args[1].func.attr == "pending_gate_entry" ] - assert len(declared_pending_writes) == 2 - for call in declared_pending_writes: - entry = call.args[1] - keys = {key.value for key in entry.keys if isinstance(key, ast.Constant)} - assert "pin_requested_provider" in keys - assert "model_override" in keys + assert len(declared_pending_writes) == 3 def test_chat_routed_tier_denial_is_a_clean_403() -> None: @@ -510,4 +508,4 @@ async def test_catalog_resolver_pricing_and_missing_key_are_fail_closed( provider_id="deepseek", use_tools=False, ) -# 400:1 0:0 0:0 +# 398:1 0:0 0:0 From 15f2581fb1de205d611cb2cd6bd6b70438ae3545 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:34 -0700 Subject: [PATCH 47/48] Extend executable approval repair witnesses --- python/tests/chec_open_comp_cont_v0.0.0alpha.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/tests/chec_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py index 9761cc5e2..b5cc0e70e 100644 --- a/python/tests/chec_open_comp_cont_v0.0.0alpha.py +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -1,4 +1,4 @@ -# 115:19 0:0 0:0 +# 117:19 0:0 0:0 """Executable msdmd witness for the generic provider boundary.""" # === CHECKS === @@ -11,7 +11,7 @@ # cleanup: none # # id: check_openai_compatible_repair_regressions -# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, tool_dispatch_enforces_approval_scope, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403 +# proves: a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, a0_openai_compatible_reasoning_floor, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, tool_dispatch_enforces_approval_scope, approval_gate_uses_current_user_turn, approval_gate_replay_is_scope_bound, approval_gate_dispatch_denial_becomes_pending, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403 # call: self::check_openai_compatible_repair_regressions # requires: python3, pytest # timeout: 60 @@ -131,6 +131,8 @@ def check_openai_compatible_repair_regressions() -> None: f"{approval_tests}::test_scoped_tool_dispatch_requires_grant_or_cleared_gate", f"{approval_tests}::test_scoped_tool_dispatch_accepts_persisted_user_scope", f"{approval_tests}::test_multimodal_approval_text_uses_only_text_parts", + f"{approval_tests}::test_approval_gate_uses_only_latest_user_turn", + f"{approval_tests}::test_dispatch_denial_becomes_scope_bound_pending_gate", f"{approval_tests}::test_legacy_openai_route_transports_through_concrete_owner", f"{approval_tests}::test_pending_replay_pins_every_resolved_model", f"{adapter_tests}::test_adapter_uses_registry_transport_and_sanitizes_failure", @@ -155,4 +157,4 @@ def check_openai_compatible_repair_regressions() -> None: def test_openai_compatible_registry_wiring() -> None: check_openai_compatible_registry_wiring() -# 115:19 0:0 0:0 +# 117:19 0:0 0:0 From c64ce3d60dff9f7c6b8900371213cd2bbe420c47 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Wed, 9 Sep 2026 18:11:35 -0700 Subject: [PATCH 48/48] Regenerate module metadata for scope-bound approvals --- a0_msdmd.ts | 114 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 100 insertions(+), 14 deletions(-) diff --git a/a0_msdmd.ts b/a0_msdmd.ts index f7ff211a7..2d6b37ea1 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -24,6 +24,17 @@ export default defineMsdmdCollection({ "file": "a0/adapters/open_comp_adap_v0.0.0alpha.py", "id": "a0_openai_compatible_error_suppresses_secret_cause" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "a standalone compatible provider declares a minimum reasoning effort above the request or default", + "since": "2026-09-10", + "then": "the adapter raises the effective effort to that configured floor before transport" + }, + "file": "a0/adapters/open_comp_adap_v0.0.0alpha.py", + "id": "a0_openai_compatible_reasoning_floor" + }, { "block": "CONTRACTS", "fields": { @@ -850,7 +861,7 @@ export default defineMsdmdCollection({ "fields": { "class": "correctness", "given": "any explicit or auto-routed single-model call stops at an approval gate", - "then": "both gate-id and scope approval replays retain the already resolved provider and concrete model pin, including any subsequently pending gate" + "then": "both replay paths retain the resolved provider/model, gate-id replay carries only the exact approved tool scopes, and any subsequent denial replaces the pending gate" }, "file": "python/routes/chat.py", "id": "chat_approval_replay_preserves_provider_pin" @@ -1006,23 +1017,56 @@ export default defineMsdmdCollection({ "file": "python/services/agent_lifecycle.py", "id": "a0_service_agent_lifecycle" }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "any tool-capable provider selects a scoped tool that text preflight did not identify", + "since": "2026-09-10", + "then": "the denial becomes a pending gate carrying the concrete tool and scope instead of an inert tool-result string" + }, + "file": "python/services/appr_gate_serv_v0.0.0alpha.py", + "id": "approval_gate_dispatch_denial_becomes_pending" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "a user approves one pending action and replay reaches a registered scoped tool", + "since": "2026-09-10", + "then": "dispatch is cleared only when the tool's declared scope exactly matches a scope recorded by that pending gate" + }, + "file": "python/services/appr_gate_serv_v0.0.0alpha.py", + "id": "approval_gate_replay_is_scope_bound" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "given": "conversation history contains an earlier gated request and a later unrelated user turn", + "since": "2026-09-10", + "then": "approval classification evaluates only the latest user turn while replay retains that original turn as the latest history entry" + }, + "file": "python/services/appr_gate_serv_v0.0.0alpha.py", + "id": "approval_gate_uses_current_user_turn" + }, { "block": "MODULE_BUILD", "fields": { "admin_only": "false", "auth_boundary": "user approval scopes", - "internal_surface": "_message_text", + "internal_surface": "_message_text, _current_user_text, _pending_gate, _tool_approval_gate_result", "module_kind": "service", "module_name": "approval_gate_service", "network_boundary": "none", "owner": "Erin Spencer", - "public_surface": "approval_gate_result", - "requires": "a0_service_openai_router", - "rollback": "Revert compatible-provider gate wiring and restore the legacy OpenAI-local gate builder.", + "public_surface": "approval_gate_result, capture_tool_approval, pending_gate_entry", + "requires": "a0_service_openai_router, a0_service_run_context, a0_service_tool_executor", + "rollback": "Revert shared transport capture and restore the legacy OpenAI-local gate builder.", "rollout": "default_enabled", "since": "2026-09-09", "storage_boundary": "read/write audit", - "summary": "Builds the policy route decision and pending approval response shared by legacy OpenAI and registry-driven compatible-provider transports.", + "summary": "Builds current-turn pending approvals and converts scoped tool-dispatch denials from every transport into exact-scope replay gates.", "tests": "tests/test_appr_tool_disp_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "read" @@ -1490,9 +1534,9 @@ export default defineMsdmdCollection({ "block": "CONTRACTS", "fields": { "class": "security", - "given": "a tool-enabled OpenAI-compatible provider turn requests an external write without a grant", + "given": "a tool-enabled provider turn requests an external write without a grant, whether text preflight or actual dispatch identifies it", "since": "2026-09-09", - "then": "inference returns a pending approval gate before transport or tool execution and replay may bypass only with skip_approval" + "then": "inference returns a pending approval gate before mutation and replay bypasses text preflight only while dispatch remains limited to the gate's exact scopes" }, "file": "python/services/inference.py", "id": "inference_compatible_provider_approval_gate" @@ -1947,19 +1991,19 @@ export default defineMsdmdCollection({ "block": "MODULE_BUILD", "fields": { "admin_only": "false", - "auth_boundary": "holds the approval-scope user id that tools read to scope pre-approved actions", + "auth_boundary": "holds the approval-scope user id and exact per-gate tool scopes used by dispatch enforcement", "internal_surface": "none", "module_kind": "service", "module_name": "run_context", "network_boundary": "none", "owner": "Erin Spencer", - "public_surface": "get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, current_approval_gate_cleared, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot", + "public_surface": "get_current_run_id, set_approval_scope_user_id, get_approval_scope_user_id, current_approval_gate_scopes, get_current_depth, get_current_root_run_id, get_current_parent_run_id, bind_run, reset_run, snapshot", "requires": "none", "rollback": "Revert this file; recursion tracking reverts to prior ContextVar surface.", "rollout": "default_enabled", "since": "2026-06-02", "storage_boundary": "none", - "summary": "Run-scoped ContextVars for ZFAE recursion tracking \u2014 run id, depth, root/parent run id, and approval-scope user id, inherited by async tool/inference calls and rebound on sub-agent spawn.", + "summary": "Run-scoped ContextVars for ZFAE recursion and exact per-gate approval scopes, inherited by async tool/inference calls and rebound on sub-agent spawn.", "tests": "tests/test_run_context.py", "unresolved": "none", "user_data_boundary": "none" @@ -2398,7 +2442,7 @@ export default defineMsdmdCollection({ "class": "security", "given": "a registered tool declares an approval_scope", "since": "2026-09-10", - "then": "its handler cannot run without either that user's persisted scope or an explicitly cleared per-gate replay context" + "then": "its handler cannot run without either that user's persisted matching scope or the same scope in an explicit per-gate replay context; a denial is surfaced for pending-gate continuation" }, "file": "python/services/tool_executor.py", "id": "tool_dispatch_enforces_approval_scope" @@ -2413,7 +2457,7 @@ export default defineMsdmdCollection({ "module_name": "tool_executor", "network_boundary": "none", "owner": "Erin Spencer", - "public_surface": "set_allowed_tools, reset_allowed_tools, get_active_chat_schemas, get_active_responses_schemas, get_a0_skill_manifest, get_a0_skill_body, TOOL_SCHEMAS_CHAT, TOOL_SCHEMAS_RESPONSES, execute_tool", + "public_surface": "set_allowed_tools, reset_allowed_tools, get_active_chat_schemas, get_active_responses_schemas, get_a0_skill_manifest, get_a0_skill_body, TOOL_SCHEMAS_CHAT, TOOL_SCHEMAS_RESPONSES, execute_tool, ToolApprovalRequired", "requires": "a0_service_tool_distill", "rollback": "Revert this file; falls back to the tools registry dispatcher directly.", "rollout": "default_enabled", @@ -2922,7 +2966,7 @@ export default defineMsdmdCollection({ "call": "self::check_openai_compatible_repair_regressions", "cleanup": "none", "mutates": "none", - "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, tool_dispatch_enforces_approval_scope, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403", + "proves": "a0_openai_compatible_error_suppresses_secret_cause, a0_openai_compatible_store_defaults_off, a0_openai_compatible_reasoning_floor, call_fn_resolved_model_pins_provider, call_fn_auto_model_keeps_role_slot_routing, inference_tool_repeat_fingerprint_ignores_transport_ids, inference_compatible_provider_receives_classified_role, inference_fanout_preserves_requested_provider, inference_auto_route_gates_and_reports_effective_provider, inference_explicit_openai_model_is_pinned, inference_compatible_provider_approval_gate, inference_compatible_transport_uses_effective_owner, tool_dispatch_enforces_approval_scope, approval_gate_uses_current_user_turn, approval_gate_replay_is_scope_bound, approval_gate_dispatch_denial_becomes_pending, openai_compatible_responses_preserves_reasoning_items, openai_stateless_reasoning_is_replayable, openai_compatible_caller_provider_is_scoped, cheap_provider_prefers_configured_low_cost_provider, catalog_routed_model_tier_follows_concrete_owner, chat_approval_replay_preserves_provider_pin, chat_routed_tier_denial_is_clean_403", "requires": "python3, pytest", "timeout": "60" }, @@ -5361,6 +5405,13 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "a0_openai_compatible_error_suppresses_secret_cause" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "a0_openai_compatible_reasoning_floor" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -5368,6 +5419,27 @@ export default defineMsdmdCollection({ "source_id": "check_openai_compatible_repair_regressions", "to": "a0_openai_compatible_store_defaults_off" }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "approval_gate_dispatch_denial_becomes_pending" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "approval_gate_replay_is_scope_bound" + }, + { + "from": "check_openai_compatible_repair_regressions", + "kind": "claims_proves", + "source_block": "CHECKS", + "source_id": "check_openai_compatible_repair_regressions", + "to": "approval_gate_uses_current_user_turn" + }, { "from": "check_openai_compatible_repair_regressions", "kind": "claims_proves", @@ -7160,6 +7232,20 @@ export default defineMsdmdCollection({ "source_id": "a0_service_approval_gate", "to": "a0_service_openai_router" }, + { + "from": "a0_service_approval_gate", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_approval_gate", + "to": "a0_service_run_context" + }, + { + "from": "a0_service_approval_gate", + "kind": "requires", + "source_block": "MODULE_BUILD", + "source_id": "a0_service_approval_gate", + "to": "a0_service_tool_executor" + }, { "from": "a0_service_artifacts", "kind": "owns",