diff --git a/CHANGELOG.md b/CHANGELOG.md index 48aadbe..147a6f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to traust-contracts are documented here. +## [0.63.0] + +### Added + +- **The provider `connection` block in the model registry** + (`config/v1/model-registry.schema.json`). Optional, so existing + registries load unchanged. sci#287 (source-code-intelligence) routes the + runner pool's model delivery through it; the Go executor parses the block + leniently, but the engine's config-completeness audit validates the + registry against this schema, so a registry carrying one failed startup + with `Additional properties are not allowed ('connection' was unexpected)` + (live on the dev cluster, 2026-11-06; a dev-only Containerfile patch + worked around it). The schema mirrors the executor's `validate()`: + - `type` (required): `anthropic`, `anthropic-compatible`, `gemini`, + `openai`, `openai-compatible` — the wire and credential format, not the + model family. Registered as `provider_connection_type`. + - `base_url`: required on the compatible types (they exist for endpoint + overrides), empty on the proprietary types (the default endpoint + serves; an empty string reads as absent, as the executor does). + - `harness`: the harness a gateway connection fronts its wire with + (`omp`, `claude`); empty keeps the default. + - `credential` (required): `{source: env | secret, name?, key?}` — a + reference, never the credential itself. `source: secret` requires the + Secret's non-empty `name` and `key`. Registered as `credential_source`. +- The block is routing data only, never credentials: the typed sections + stay permissive (`ProviderSpec` keeps it as an extra field) and the + schema refuses any other key on the block. +- traust-core/engine pin bumps pick this up (a parallel engine PR notes + the dependency). + ## [0.62.3] ### Fixed diff --git a/VERSION b/VERSION index eb324e6..70cd226 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.62.3 +0.63.0 diff --git a/config/v1/model-registry.schema.json b/config/v1/model-registry.schema.json index 7966eba..a7bf599 100644 --- a/config/v1/model-registry.schema.json +++ b/config/v1/model-registry.schema.json @@ -52,6 +52,135 @@ "type": "string", "minLength": 4 }, + "connection": { + "type": "object", + "description": "Provider routing: the wire this provider speaks, its endpoint, its harness and where its credential lives. Routing data only, never the credentials themselves \u2014 a secret credential names the Secret and the key within it, and the credential reaches the worker on the deployment's existing mechanism. Absent keeps the default routing (the Anthropic wire, the pod's existing credential).", + "required": [ + "type", + "credential" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "anthropic", + "anthropic-compatible", + "gemini", + "openai", + "openai-compatible" + ], + "description": "The wire and credential format, not the model family: proprietary endpoints (anthropic, gemini, openai) or compatible gateways speaking an incumbent's wire (anthropic-compatible, openai-compatible). A gateway serving both wires appears as two providers." + }, + "base_url": { + "type": "string", + "description": "The gateway endpoint. Required on the compatible types, which exist for endpoint overrides; empty on the proprietary types, whose default endpoint serves." + }, + "harness": { + "type": "string", + "description": "The harness a gateway connection fronts its wire with (omp or claude); empty keeps the default." + }, + "credential": { + "type": "object", + "description": "Where the provider's credential lives \u2014 a reference, never the credential itself. Source env reads the pod's existing environment; source secret names a Kubernetes Secret and the key within it, both required.", + "required": [ + "source" + ], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "enum": [ + "env", + "secret" + ] + }, + "name": { + "type": "string", + "description": "The Secret's name (source secret)." + }, + "key": { + "type": "string", + "description": "The credential's key within the Secret (source secret)." + } + }, + "if": { + "properties": { + "source": { + "const": "secret" + } + }, + "required": [ + "source" + ] + }, + "then": { + "required": [ + "name", + "key" + ], + "properties": { + "name": { + "minLength": 1 + }, + "key": { + "minLength": 1 + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "anthropic-compatible", + "openai-compatible" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "base_url" + ], + "properties": { + "base_url": { + "minLength": 1 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "enum": [ + "anthropic", + "gemini", + "openai" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "base_url": { + "maxLength": 0 + } + } + } + } + ] + }, "models": { "type": "object", "minProperties": 1, diff --git a/enums/v1/credential-source.json b/enums/v1/credential-source.json new file mode 100644 index 0000000..e59ca17 --- /dev/null +++ b/enums/v1/credential-source.json @@ -0,0 +1,16 @@ +{ + "name": "credential_source", + "source_schema": "model-registry.schema.json#/properties/providers/additionalProperties/properties/connection/properties/credential/properties/source", + "description": "Values of `connection.credential.source` in model-registry.schema.json \u2014 where a provider's credential lives.", + "values": [ + "env", + "secret" + ], + "used_in_schemas": [ + "model-registry.schema.json" + ], + "definitions": { + "env": "The pod's existing environment (the model Secret's EnvFrom where the deployment wires one).", + "secret": "A Kubernetes Secret in the Job's namespace; the Secret's name and the key within it are both required." + } +} diff --git a/enums/v1/provider-connection-type.json b/enums/v1/provider-connection-type.json new file mode 100644 index 0000000..93e3a3b --- /dev/null +++ b/enums/v1/provider-connection-type.json @@ -0,0 +1,22 @@ +{ + "name": "provider_connection_type", + "source_schema": "model-registry.schema.json#/properties/providers/additionalProperties/properties/connection/properties/type", + "description": "Values of `connection.type` in model-registry.schema.json \u2014 the wire a registry provider speaks.", + "values": [ + "anthropic", + "anthropic-compatible", + "gemini", + "openai", + "openai-compatible" + ], + "used_in_schemas": [ + "model-registry.schema.json" + ], + "definitions": { + "anthropic": "The proprietary Anthropic endpoint; no base_url (the default endpoint serves).", + "anthropic-compatible": "A gateway speaking the Anthropic wire; requires a base_url endpoint override.", + "gemini": "Google's proprietary API; no base_url (the default endpoint serves).", + "openai": "The proprietary OpenAI endpoint; no base_url (the default endpoint serves).", + "openai-compatible": "A gateway speaking the OpenAI wire; requires a base_url endpoint override." + } +} diff --git a/pyproject.toml b/pyproject.toml index 0c20665..f69aabc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "traust-contracts" -version = "0.62.3" +version = "0.63.0" license = "Apache-2.0" description = "Contract surface for the AI security ledger ecosystem: schemas and shared enums" requires-python = ">=3.11" diff --git a/tests/test_model_registry_connections.py b/tests/test_model_registry_connections.py new file mode 100644 index 0000000..9f9068b --- /dev/null +++ b/tests/test_model_registry_connections.py @@ -0,0 +1,178 @@ +"""Provider connection blocks in the model registry. + +sci#287 (source-code-intelligence) routes the runner pool's model delivery +through a provider's connection block: the wire it speaks, its endpoint, the +harness fronting it and where its credential lives. The Go executor parses +the block leniently, but the engine's config-completeness audit validates the +registry against this schema, so a registry carrying a connection block +failed startup until the block was declared here. The schema mirrors the +executor's validate() exactly: the type vocabulary, the compatible types' +required endpoint, the proprietary types' refusal of one, and a secret +credential's required Secret name and key. The block is routing data only — +never the credentials themselves. +""" + +from __future__ import annotations + +from pathlib import Path + +import jsonschema +import pytest +import yaml + +from traust_contracts.config import load_section + + +def _registry(connection: dict | None) -> dict: + provider: dict = { + "data_handling": "test", + "models": {"m": {"tier": "haiku-class", "batch_eligible": True}}, + } + if connection is not None: + provider["connection"] = connection + return { + "version": 1, + "updated": "2026-11-06", + "tiers": {"haiku-class": {"description": "h"}}, + "providers": {"enmaas": provider}, + "roles": { + "r": { + "floor": "haiku-class", + "approved": ["m"], + "candidates": [], + "ledger_validity_writer": False, + } + }, + } + + +def _load(tmp_path: Path, connection: dict | None): + (tmp_path / "model-registry.yaml").write_text(yaml.safe_dump(_registry(connection))) + return load_section("model-registry.yaml", config_home=tmp_path) + + +@pytest.mark.parametrize( + "connection", + [ + # The dev cluster's shape (providers/enmaas): an OpenAI-compatible + # gateway fronted by the omp harness, its credential a named Secret. + { + "type": "openai-compatible", + "base_url": "https://api.enmaas.devshift.net", + "harness": "omp", + "credential": { + "source": "secret", + "name": "sci-enmaas-model", + "key": "ANTHROPIC_API_KEY", + }, + }, + { + "type": "anthropic-compatible", + "base_url": "https://gw.example", + "harness": "claude", + "credential": {"source": "env"}, + }, + # A proprietary endpoint: no base_url, the credential already in the + # pod's environment. + {"type": "anthropic", "credential": {"source": "env"}}, + # An empty base_url reads as absent, as the executor's validate() does. + {"type": "anthropic", "base_url": "", "credential": {"source": "env"}}, + # Known vocabulary the worker has no lane for yet: the shape is valid, + # the executor refuses at delivery until the worker grows the lane. + {"type": "gemini", "credential": {"source": "env"}}, + {"type": "openai", "credential": {"source": "env"}}, + ], + ids=[ + "openai-compatible gateway with a secret credential", + "anthropic-compatible gateway with the claude harness", + "proprietary anthropic endpoint", + "empty base_url reads as absent", + "gemini vocabulary", + "openai vocabulary", + ], +) +def test_a_connection_block_validates(tmp_path, connection): + reg = _load(tmp_path, connection) + # The typed section is permissive by design (the schema is the + # authority); the block survives the load for the section's consumers. + assert reg.providers["enmaas"].connection == connection + + +def test_a_provider_without_a_connection_still_loads(tmp_path): + """Registries predating connections are exactly as valid as before.""" + reg = _load(tmp_path, None) + assert reg.all_models()["m"].tier == "haiku-class" + + +@pytest.mark.parametrize( + "connection", + [ + # An unknown type or credential source would silently misroute. + {"type": "vertex-ai", "credential": {"source": "env"}}, + {"type": "anthropic", "credential": {"source": "vault"}}, + # The type names the wire; the credential names where it lives. + {"credential": {"source": "env"}}, + {"type": "anthropic"}, + # The compatible types exist for endpoint overrides: they need one. + {"type": "openai-compatible", "credential": {"source": "env"}}, + {"type": "anthropic-compatible", "base_url": "", "credential": {"source": "env"}}, + # A base_url on a proprietary type contradicts the type: the default + # endpoint serves. + {"type": "anthropic", "base_url": "https://gw.example", "credential": {"source": "env"}}, + {"type": "gemini", "base_url": "https://gw.example", "credential": {"source": "env"}}, + {"type": "openai", "base_url": "https://gw.example", "credential": {"source": "env"}}, + # A secret credential names nothing verifiable without the Secret's + # name and the key within it — and an empty string names nothing + # either, as the executor's validate() refuses. + { + "type": "openai-compatible", + "base_url": "https://gw.example", + "credential": {"source": "secret", "name": "sci-enmaas-model"}, + }, + { + "type": "openai-compatible", + "base_url": "https://gw.example", + "credential": {"source": "secret", "key": "ANTHROPIC_API_KEY"}, + }, + { + "type": "openai-compatible", + "base_url": "https://gw.example", + "credential": {"source": "secret"}, + }, + { + "type": "openai-compatible", + "base_url": "https://gw.example", + "credential": {"source": "secret", "name": "", "key": "ANTHROPIC_API_KEY"}, + }, + { + "type": "openai-compatible", + "base_url": "https://gw.example", + "credential": {"source": "secret", "name": "sci-enmaas-model", "key": ""}, + }, + # The block is routing data, never the credentials themselves: an + # inline credential or any other stray key is refused. + {"type": "anthropic", "credential": {"source": "env"}, "api_key": "sk-ant-example"}, + {"type": "anthropic", "credential": {"source": "env"}, "endpoint": "https://gw.example"}, + ], + ids=[ + "unknown connection type", + "unknown credential source", + "missing type", + "missing credential", + "compatible type without a base_url", + "compatible type with an empty base_url", + "base_url on anthropic", + "base_url on gemini", + "base_url on openai", + "secret credential without the key", + "secret credential without the Secret name", + "secret credential without both", + "secret credential with an empty Secret name", + "secret credential with an empty key", + "inline credential", + "unknown connection property", + ], +) +def test_unroutable_connections_are_rejected(tmp_path, connection): + with pytest.raises(jsonschema.ValidationError): + _load(tmp_path, connection) diff --git a/uv.lock b/uv.lock index 1aa4822..56f50cc 100644 --- a/uv.lock +++ b/uv.lock @@ -641,7 +641,7 @@ wheels = [ [[package]] name = "traust-contracts" -version = "0.62.3" +version = "0.63.0" source = { editable = "." } dependencies = [ { name = "jsonschema", extra = ["format-nongpl"] },