diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69a0210a..4db9de50 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,33 @@ 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_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 runs-on: ubuntu-latest @@ -125,7 +152,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 +197,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 257931c5..1a4a425a 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 eafb11e5..2bd6dee9 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/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. @@ -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 02207217..9294667d 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 b8c26e2b..61292aca 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/__init__.py b/a0/__init__.py index abc9e0ee..9a312881 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 9b5386b0..073d3375 100644 --- a/a0/adapters/__init__.py +++ b/a0/adapters/__init__.py @@ -1,6 +1,35 @@ -# 3:0 0:0 0:2 +# 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 .subagents import ALL_SUBAGENTS, MODE_SUBAGENTS -__all__ = ["ClaudeAgentAdapter", "ALL_SUBAGENTS", "MODE_SUBAGENTS"] -# 3:0 0:0 0:2 + +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", +] +# 27:0 0:0 0:3 diff --git a/a0/adapters/open_comp_adap_v0.0.0alpha.py b/a0/adapters/open_comp_adap_v0.0.0alpha.py new file mode 100644 index 00000000..d3df0734 --- /dev/null +++ b/a0/adapters/open_comp_adap_v0.0.0alpha.py @@ -0,0 +1,165 @@ +# 100:47 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_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 +# 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 +# +# 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 +# +# 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 +# +# 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 +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: + 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 + + +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) + 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] = { + "model": self.model, + "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) + 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 "") + except Exception as exc: + raise RuntimeError( + f"{self.provider_id} request failed: {type(exc).__name__}" + ) from None + + 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": [], + } +# 100:47 0:0 2:0 diff --git a/a0/adapters/openai_adapter.py b/a0/adapters/openai_adapter.py deleted file mode 100644 index f959c5ce..00000000 --- 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/prov_regi_v0.0.0alpha.py b/a0/prov_regi_v0.0.0alpha.py new file mode 100644 index 00000000..f23c6faf --- /dev/null +++ b/a0/prov_regi_v0.0.0alpha.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_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 +# 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 2dcef115..6bb66a8b 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 . 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 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 97630ee6..2d6b37ea 100644 --- a/a0_msdmd.ts +++ b/a0_msdmd.ts @@ -1,8 +1,110 @@ -// 7594: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/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": "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": { + "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": { + "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_aone_open_comp_adap_v0.0.0alpha.py", + "unresolved": "none", + "user_data_boundary": "write" + }, + "file": "a0/adapters/open_comp_adap_v0.0.0alpha.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/prov_regi_v0.0.0alpha.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_aone_open_comp_adap_v0.0.0alpha.py", + "unresolved": "none", + "user_data_boundary": "none" + }, + "file": "a0/prov_regi_v0.0.0alpha.py", + "id": "a0_provider_registry" + }, { "block": "BOUNDARIES", "fields": { @@ -754,6 +856,16 @@ export default defineMsdmdCollection({ "file": "python/routes/billing.py", "id": "billing_webhook_replay_idempotent" }, + { + "block": "CONTRACTS", + "fields": { + "class": "correctness", + "given": "any explicit or auto-routed single-model call stops at an approval 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" + }, { "block": "CONTRACTS", "fields": { @@ -774,6 +886,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": { @@ -895,6 +1017,63 @@ 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, _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, 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 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" + }, + "file": "python/services/appr_gate_serv_v0.0.0alpha.py", + "id": "a0_service_approval_gate" + }, { "block": "MODULE_BUILD", "fields": { @@ -967,6 +1146,28 @@ 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": { + "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": { @@ -1147,6 +1348,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": { @@ -1164,7 +1376,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" }, @@ -1307,6 +1519,83 @@ 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 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" + }, + { + "block": "CONTRACTS", + "fields": { + "class": "security", + "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 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" + }, + { + "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": "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": { + "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": { + "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": { @@ -1318,12 +1607,12 @@ 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", "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" @@ -1379,6 +1668,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": { @@ -1389,7 +1689,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", @@ -1547,24 +1847,92 @@ 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": { + "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/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": { + "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_open_comp_prov_v0.0.0alpha.py", + "unresolved": "none", + "user_data_boundary": "write" + }, + "file": "python/services/providers/open_comp_prov_v0.0.0alpha.py", + "id": "a0_service_providers_openai_compatible" + }, { "block": "MODULE_BUILD", "fields": { "admin_only": "false", "auth_boundary": "none", - "internal_surface": "_call_responses", + "internal_surface": "none", "module_kind": "adapter", "module_name": "openai_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", - "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_open_comp_prov_v0.0.0alpha.py", "unresolved": "none", "user_data_boundary": "write" }, @@ -1623,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, 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" @@ -2068,17 +2436,28 @@ 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 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" + }, { "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", "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", @@ -2568,6 +2947,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, 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" + }, + "file": "python/tests/chec_open_comp_cont_v0.0.0alpha.py", + "id": "check_openai_compatible_repair_regressions" + }, { "block": "CONTRACTS", "fields": { @@ -4951,6 +5356,223 @@ 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_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_reasoning_floor" + }, + { + "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": "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", + "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", + "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": "catalog_routed_model_tier_follows_concrete_owner" + }, + { + "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": "chat_routed_tier_denial_is_clean_403" + }, + { + "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", + "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", + "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", + "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_compatible_transport_uses_effective_owner" + }, + { + "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", + "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": "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", + "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", @@ -6113,6 +6735,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 +7071,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", @@ -6568,6 +7218,34 @@ 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_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", @@ -6764,6 +7442,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", @@ -6965,27 +7650,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 +8300,3 @@ export default defineMsdmdCollection({ "gaps": [], "repo": "a0" }); -// 7594:0 0:1 0:1 diff --git a/capacitor.config.ts b/capacitor.config.ts index 725fbff0..7e78a5b3 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/client/src/components/chat-input.tsx b/client/src/components/chat-input.tsx index 5ed1426a..9170dffe 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/cloudbuild.yaml b/cloudbuild.yaml index 346826d2..d030dc4e 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -15,6 +15,29 @@ 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_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 runs-on: ubuntu-latest @@ -93,7 +116,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 +175,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 b6e2a188..62b3f89c 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 + - `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/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 @@ -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/pyproject.toml b/pyproject.toml index c5e61793..97508eb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,10 @@ 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"] +"python.config" = ["providers.json", "pricing.json"] + [project] name = "repl-nix-workspace" @@ -43,6 +47,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/config/openai_policy.json b/python/config/openai_policy.json index b89845e9..cd3f34ae 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, diff --git a/python/config/pricing.json b/python/config/pricing.json index ae255d3b..6a29aff0 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 0a832e7b..9d7ff3dc 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 e5c4de55..17f830eb 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/routes/chat.py b/python/routes/chat.py index 3b361b75..47701c41 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 637:184 2:7 2:16 +# 672:191 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -8,10 +8,11 @@ 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 -# 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] = {} @@ -81,6 +82,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: @@ -372,6 +374,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 @@ -483,11 +486,23 @@ 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) + ), + 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 = ( @@ -597,6 +612,11 @@ 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) + ), + model_override=pending.get("model_override"), + routed_user_tier=tier, ) finally: set_approval_scope_user_id(None) @@ -604,16 +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"], - "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 @@ -784,6 +800,8 @@ 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, + 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 @@ -831,15 +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, - "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) @@ -878,6 +892,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}") @@ -902,5 +934,15 @@ 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: any explicit or auto-routed single-model call stops at an approval 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 +# 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 === -# 637:184 2:7 2:16 +# 672:191 2:7 2:16 diff --git a/python/routes/instances_api.py b/python/routes/instances_api.py index bb9240c1..f0e7af00 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/__init__.py b/python/services/__init__.py index f959c5ce..5dbf6dc5 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 diff --git a/python/services/agent_instance.py b/python/services/agent_instance.py index 5603b3be..0853feab 100644 --- a/python/services/agent_instance.py +++ b/python/services/agent_instance.py @@ -1,4 +1,4 @@ -# 112: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: @@ -95,6 +95,8 @@ async def run( max_tokens: int = 8000, 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. @@ -117,9 +119,14 @@ async def run( reasoning_effort=reasoning_effort, 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 @@ -196,4 +203,4 @@ def __repr__(self) -> str: f"tools={self.use_tools}, " f"resolved={self.provider_id!r})" ) -# 112:67 0:0 3:3 +# 119:67 0:0 3:3 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 00000000..638b2222 --- /dev/null +++ b/python/services/appr_gate_serv_v0.0.0alpha.py @@ -0,0 +1,210 @@ +# 142:45 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 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, 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 +# user_data_boundary: read +# admin_only: false +# tests: tests/test_appr_tool_disp_v0.0.0alpha.py +# rollout: default_enabled +# 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, Awaitable, Optional, Sequence + + +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) + + +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" + ), + "", + ) + + +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]}" + 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", + ) + 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, + } + 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" + 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) + + +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 diff --git a/python/services/call_fn.py b/python/services/call_fn.py index f0f2c858..3ec13fc2 100644 --- a/python/services/call_fn.py +++ b/python/services/call_fn.py @@ -1,4 +1,4 @@ -# 99:73 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 @@ -54,6 +54,20 @@ # 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 +# +# 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 from .inference import call_provider @@ -96,6 +110,8 @@ async def call_model( reasoning_effort: Optional[str] = None, 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. @@ -111,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): @@ -132,6 +149,9 @@ async def call_model( user_id=user_id, 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 @@ -195,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 -# 99:73 0:0 3:2 +# 105:86 0:0 3:2 diff --git a/python/services/energy_registry.py b/python/services/energy_registry.py index 508c3698..8b511c7b 100644 --- a/python/services/energy_registry.py +++ b/python/services/energy_registry.py @@ -1,4 +1,4 @@ -# 289:88 0:0 19: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). @@ -98,8 +106,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 @@ -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 ] @@ -157,8 +166,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() @@ -305,6 +314,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) @@ -400,8 +410,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 +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 19:3 +# 291:95 0:0 20:3 diff --git a/python/services/gemini_native.py b/python/services/gemini_native.py index 1a1fcffa..96957d14 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 diff --git a/python/services/inference.py b/python/services/inference.py index d094dd75..d286f263 100644 --- a/python/services/inference.py +++ b/python/services/inference.py @@ -1,9 +1,9 @@ -# 393:107 0:0 16:14 +# 388:157 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 @@ -15,25 +15,59 @@ # 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 === -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 +# +# 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 +# +# 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 +# +# 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 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 +# +# 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 +# +# id: inference_compatible_provider_approval_gate +# 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 +# +# 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 copy -import random -import asyncio import logging -from typing import Optional, Callable, Awaitable, Any +import os +from typing import Awaitable, Callable, Optional, Sequence 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. @@ -55,9 +89,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 +142,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 " @@ -225,7 +261,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: @@ -242,6 +278,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" @@ -276,28 +314,40 @@ 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, + 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, + 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 + 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) + - 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) @@ -311,17 +361,25 @@ 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: provider_id = _slot_provider - 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, + 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) if not spec: @@ -329,18 +387,45 @@ 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) + # 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] + messages = _build_provider_messages(messages, effective_provider_id) + + 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, + reasoning_effort=reasoning_effort, + ) + 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 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,20 +438,18 @@ 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( - payload_messages, - model_override=spec["model"], - api_key=api_key, - max_tokens=max_tokens, + from .providers import openai_compatible_provider + 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, - ) + reasoning_effort=effective_effort, pin_model_override=True)) + return _attribute_provider(result, effective_provider_id, effective_model) - 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,38 +460,38 @@ async def call_provider( vendor = spec.get("vendor", "") + if spec.get("adapter") == "openai-compatible": + from .providers import openai_compatible_provider + 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, progress_callback=progress_callback)) + return _attribute_provider(result, effective_provider_id, effective_model) + if vendor == "anthropic": - return 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), - ) + 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))) + return _attribute_provider(result, effective_provider_id, effective_model) 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, + 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 - return 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, - ) + 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)) + 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 @@ -420,11 +503,11 @@ 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, + 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, @@ -433,71 +516,34 @@ 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 . 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") - - 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}") + task_text = approval_gate_service._current_user_text(messages) - 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) + 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"], + ) + if pending is not None: + return pending - 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, - output_text=output_repr, - approval_state="pending", - ) - usage = { - "approval_state": "pending", - "gate_id": gate_id, - "approval_packet": packet, - "route_decision": route_decision, - } - 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 - - 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." ) @@ -506,17 +552,23 @@ 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 + 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"], + use_tools=use_tools, reasoning_effort=call_cfg["reasoning_effort"], temperature=call_cfg["temperature"], store=call_cfg["store"], - ) + 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) await log_openai_event( @@ -551,14 +603,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) -# 393:107 0:0 16:14 +# 388:157 0:0 16:15 diff --git a/python/services/model_catalog.py b/python/services/model_catalog.py index 59eee10d..e0809416 100644 --- a/python/services/model_catalog.py +++ b/python/services/model_catalog.py @@ -1,4 +1,4 @@ -# 108:86 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 ( @@ -70,9 +78,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(): @@ -80,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). @@ -106,11 +162,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 +216,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") @@ -201,6 +257,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) @@ -225,4 +284,4 @@ def _touch(mid: str) -> dict: }) return {"user_tier": user_tier, "providers": out_providers} -# 108:86 0:0 7:1 +# 150:98 0:0 7:1 diff --git a/python/services/providers/__init__.py b/python/services/providers/__init__.py index 917720bf..74d5e028 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: @@ -14,13 +14,36 @@ 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. """ +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/_resolver.py b/python/services/providers/_resolver.py index baf3d481..1dc2ea27 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/open_comp_prov_v0.0.0alpha.py b/python/services/providers/open_comp_prov_v0.0.0alpha.py new file mode 100644 index 00000000..6ae7128f --- /dev/null +++ b/python/services/providers/open_comp_prov_v0.0.0alpha.py @@ -0,0 +1,446 @@ +# 342:55 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_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 +# 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 +# +# 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 +# +# 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 +# +# 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 +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 _safe_error_snippet + + 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]: + 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: + 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 + + +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 supports_store and not store: + kwargs["include"] = ["reasoning.encrypted_content"] + 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_executor import execute_tool + + 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 + + # 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", "{}")) + 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_executor import execute_tool, get_active_chat_schemas + + 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, + 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.""" + 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") + + 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") + 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}" + ) + finally: + reset_caller_provider(caller_provider_token) +# 342:55 0:0 2:5 diff --git a/python/services/providers/openai_provider.py b/python/services/providers/openai_provider.py index 6b35ce0d..f2aff12a 100644 --- a/python/services/providers/openai_provider.py +++ b/python/services/providers/openai_provider.py @@ -1,49 +1,31 @@ -# 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. -""" +# 29: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 +# internal_surface: none # auth_boundary: none # storage_boundary: none # network_boundary: external # user_data_boundary: write # admin_only: false -# tests: hmmm +# tests: tests/test_open_comp_prov_v0.0.0alpha.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 . import openai_compatible_provider async def call( @@ -57,173 +39,20 @@ async def call( reasoning_effort: Optional[str] = "medium", temperature: float = 1.0, store: bool = False, + pin_model_override: 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 openai_compatible_provider.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, + pin_model_override=pin_model_override, ) - - 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 +# 29:22 0:0 1:1 diff --git a/python/services/providers/xai_provider.py b/python/services/providers/xai_provider.py index 413e3ca8..883c1c3f 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 @@ -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) @@ -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 diff --git a/python/services/run_context.py b/python/services/run_context.py index 07e7de7a..74f83fed 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. @@ -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, 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,6 +60,9 @@ current_max_tool_rounds: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar( "a0p_max_tool_rounds", default=None, ) +current_approval_gate_scopes: contextvars.ContextVar[frozenset[str]] = contextvars.ContextVar( + "approval_gate_scopes", default=frozenset(), +) 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_scopes": sorted(current_approval_gate_scopes.get()), } # N:M -# 70:33 0:0 9:0 +# 74:33 0:0 9:0 diff --git a/python/services/tool_executor.py b/python/services/tool_executor.py index dd88d44a..dc9d2e68 100644 --- a/python/services/tool_executor.py +++ b/python/services/tool_executor.py @@ -1,4 +1,4 @@ -# 352:92 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,9 +21,9 @@ # 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 -# 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 +# 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 # 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 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 === + import contextvars as _cv import contextvars import os @@ -463,6 +471,35 @@ async def execute_tool(name: str, arguments: dict) -> str: return await _maybe_summarize(name, arguments or {}, raw, call_id) +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_scopes + if scope in current_approval_gate_scopes.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}") + raise ToolApprovalRequired(name, scope) + + 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,10 +513,13 @@ async def _execute_tool_inner(name: str, arguments: dict) -> str: ) if name == "skill_load": return _skill_load(args.get("name", "")) + 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}]" @@ -490,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", @@ -504,4 +545,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 +# 378:101 0:0 12:4 diff --git a/python/tests/chec_open_comp_cont_v0.0.0alpha.py b/python/tests/chec_open_comp_cont_v0.0.0alpha.py new file mode 100644 index 00000000..b5cc0e70 --- /dev/null +++ b/python/tests/chec_open_comp_cont_v0.0.0alpha.py @@ -0,0 +1,160 @@ +# 117:19 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 +# +# id: check_openai_compatible_repair_regressions +# 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 +# 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 + + +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 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 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" + 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", + 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"{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_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"{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_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", + ] + 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() +# 117:19 0:0 0:0 diff --git a/python/tests/contract_runner.py b/python/tests/contract_runner.py index a6c01a83..3ce23ee3 100644 --- a/python/tests/contract_runner.py +++ b/python/tests/contract_runner.py @@ -1,4 +1,4 @@ -# 227: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,8 +47,10 @@ # === END CONTRACTS === from __future__ import annotations +import argparse import ast import asyncio +import hashlib import importlib import importlib.util import sys @@ -229,8 +231,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 { @@ -269,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) @@ -286,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: @@ -316,5 +338,14 @@ async def main() -> int: if __name__ == "__main__": - sys.exit(asyncio.run(main())) -# 227: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 7a9a8e4e..4cd53b15 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 @@ -73,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 @@ -113,6 +116,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/open_comp_prov_v0.0.0alpha.py python/services/providers/openai_provider.py python/services/providers/xai_provider.py python/services/tools/__init__.py @@ -140,6 +144,7 @@ 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 @@ -166,6 +171,8 @@ 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_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 @@ -174,6 +181,8 @@ tests/test_hmmm_boundary.py tests/test_inference_modes_usage.py tests/test_interdependent_bootstrap.py tests/test_live_server.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 ee30cd4a..6e984d14 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 `open_comp_adap_v0.0.0alpha.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_package.py b/tests/test_a0_package.py index 2e103813..3180bb06 100644 --- a/tests/test_a0_package.py +++ b/tests/test_a0_package.py @@ -1,4 +1,4 @@ -# 63: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 @@ -8,8 +8,10 @@ import importlib import json import pkgutil +from pathlib import Path import subprocess import sys +import tomllib import pytest @@ -91,4 +93,19 @@ 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 + + +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_aone_open_comp_adap_v0.0.0alpha.py b/tests/test_aone_open_comp_adap_v0.0.0alpha.py new file mode 100644 index 00000000..1b781b46 --- /dev/null +++ b/tests/test_aone_open_comp_adap_v0.0.0alpha.py @@ -0,0 +1,162 @@ +# 121: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 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 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 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 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 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() + 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 + + 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, +) -> 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) +# 121:1 0:0 0:0 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 00000000..24de9ef3 --- /dev/null +++ b/tests/test_appr_tool_disp_v0.0.0alpha.py @@ -0,0 +1,224 @@ +# 185: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_scopes + from python.services.tool_executor import ToolApprovalRequired + + 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"), + "post_tweet": SimpleNamespace(approval_scope="publish"), + }, + ) + monkeypatch.setattr(tool_executor, "_registry_dispatch", fake_dispatch) + tool_executor.set_approval_scope_user_id(None) + 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_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_scopes.reset(token) + assert allowed == "mutated" + assert calls == [ + ("post_tweet", {}), + ("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_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, +) -> 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: + from python.services import approval_gate_service + + source = ( + Path(__file__).resolve().parents[1] / "python/routes/chat.py" + ).read_text(encoding="utf-8") + 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 diff --git a/tests/test_open_comp_prov_v0.0.0alpha.py b/tests/test_open_comp_prov_v0.0.0alpha.py new file mode 100644 index 00000000..4c878ab7 --- /dev/null +++ b/tests/test_open_comp_prov_v0.0.0alpha.py @@ -0,0 +1,494 @@ +# 400: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 + + +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) + + +@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" + + +@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 + + 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_stateless_openai_reasoning_requests_encrypted_state() -> None: + from python.services.providers import openai_compatible_provider + + 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, + ) + 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 + + 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.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": []} + + 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") + + 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} + 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_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": "reasoning", + "id": "reasoning-1", + "encrypted_content": "opaque-state", + "summary": [], + }, + { + "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 + 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 +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, +) -> 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.tool_distill import ( + get_caller_provider, + reset_caller_provider, + set_caller_provider, + ) + 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") + 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 + 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 + + +# 400: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 00000000..6c74d146 --- /dev/null +++ b/tests/test_open_comp_rout_v0.0.0alpha.py @@ -0,0 +1,511 @@ +# 398: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 + + +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) + + +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 + ) + assert all( + any(keyword.arg == "model_override" for keyword in call.keywords) + 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) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_store_pending_gate" + ] + declared_pending_writes = [ + 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) == 3 + + +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 +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_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_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["provider_id"] == "deepseek-pro" + 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, +) -> 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, +) -> 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, + 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 +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, + "provider_id": "deepseek", + "model_id": "deepseek-v4-flash", + } + 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, 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" + 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, + ) +# 398:1 0:0 0:0